taketiyo.log

Web Engineering 🛠 & Body Building 💪

【Flutter + Firebase】アプリ内課金(IAP)のステップバイステップ実装ガイド【レシート検証】

Programming

  / / /

この記事はFlutterとFirebaseを利用してアプリ内課金(IAP)機能を実装するためのステップバイステップ実装ガイドです。
主にIAPを実装する際の肝となるレシート検証処理を、プラットフォーム別で詳細に解説します。
 

目次

 

in_app_purchaseパッケージの導入

Flutterでアプリ内課金を実装をするためのパッケージはいくつか存在しますが、今回はFlutterの公式ライブラリであるin_app_purchaseを採用します。

pubspec.yamlにパッケージを追加し、flutter pub getコマンドを実行します。

dependencies:
  in_app_purchase: 0.3.4 # 導入時点での最新版を確認すること

 

実装手順

具体的な実装手順に関しては 公式のデモプロジェクト に付属しているREADME.mdを参考にするのがおすすめです。
こちらのREADME.mdは、デモプロジェクトをAndroid、iOS、それぞれで動作させるための手順が記載されているものになりますが、各ストア(Play Developer Console, App Store Connect)の管理画面にてアイテムを登録するステップや、アイテムが有効化されるまでの手順も含めて解説されているので、実装する前にこちらのチュートリアルを完了させておくと、実装されたコードとストアとの連携イメージがより深く理解出来るでしょう。

実際の実装コード(Dart)はデモプロジェクト内のexample/lib/main.dartにあります。

実際にアプリに組み込む際もこちらのサンプルコードの流れを踏襲しつつ、多少カスタマイズを施す程度で済むかと思います。
 

レシートの検証

Android、iOS共にアプリケーション側にてアイテムの購入が完了すると、購入完了の証明書としてレシートが発行されます。
そのレシートの正当性を検証し、正しいレシートであると判定された場合にのみ、サービスを提供するよう実装する必要があります。

公式のサンプルコード(example/lib/main.dart)でいう360行目が、各プラットフォームでの購入処理が完了した際に呼ばれる処理です。

そして368行目にある_verifyPurchase()を適切に実装し、レシートの検証を行う必要があります。

サンプルコードの_verifyPurchase()350行目ですが、下記の通り常にtrueを返却するようなダミー実装となっており、コメントを見ても分かる通り実装に関する詳細は解説されていません。

Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) {
  // IMPORTANT!! Always verify a purchase before delivering the product.
  // For the purpose of an example, we directly return true.
  return Future<bool>.value(true);
}

 
レシートの検証はIAPを実装する上で肝となる非常に重要な部分ですので、次項よりそれぞれのプラットフォーム(Android, iOS)におけるレシート検証の実装を詳細に解説していきます。
 

レシート検証の流れ

Android、iOS共にレシート検証にてチェックする観点は大きく分けて次の3つです。

  • 正式なレシートであるか(改竄されていないか)
  • 自身のアプリ内アイテムのレシートであるか
  • サブスクリプションタイプのレシートの場合、有効期限内であるか

 
上記の内容をサーバーサイドにて検証すればOKです。

警告: アプリ内のみでレシートの検証を完結させる実装は、アプリがリバースエンジニアリングを受けた場合危険に晒されるため推奨されません。
必ずサーバーサイドでのレシート検証を行うようにしましょう。

 
まずはレシート検証処理の流れを図で把握しておきましょう。
Android、iOS共に大まかな流れは共通です。
 

 

Androidの場合の実装サンプル

アプリ側

_verifyPurchase()の実装は下記の様な流れになります。

Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async
{
  if (Platform.isAndroid) {
    try {
      /// Androidの場合アイテムの購入時、レシートと同時にそのレシートの署名を取得出来ます。
      /// `purchaseDetails.billingClientPurchase.signature`がレシートの署名データ
      /// `purchaseDetails.verificationData.localVerificationData`が生のレシートデータとなります。
      final body = json.encode({
        'signature': purchaseDetails.billingClientPurchase.signature,
        'receipt': purchaseDetails.verificationData.localVerificationData,
      })

      /// `RECEIPT_VERIFICATION_ENDPOINT_FOR_ANDROID`にはCloudFunctionsのエンドポイントが設定されている想定です。
      /// 双方のデータをレシート検証用エンドポイントに送信し、ステータスコード200が返却されれば検証は完了です。
      /// 200以外のステータスコードを受信した場合、`catch`にて補足され即座に`false`が返却されます。
      await http.post(RECEIPT_VERIFICATION_ENDPOINT_FOR_ANDROID, body: body);

      /// 以下はレシート検証が正常に完了した場合の実装サンプルです。
      /// isAutoRenewing = true の場合、定期購読タイプのアイテムであると判定出来ます。
      final typeOfSubscription = purchaseDetails.billingClientPurchase.isAutoRenewing;
      if (typeOfSubscription) {
        /// 定期購読タイプのアイテムの場合の処理
      } else {
        /// 非消費型、または消費型アイテムの場合の処理
      }
    } catch (e) {
      return false;
    }
  }

  ...

  return true;
}

 

サーバー側

今回検証用のエンドポイントにはFirebaseのCloudFunctionsを利用します。
まずは検証処理に必要なサービスアカウントの認証情報と、PlayStoreの公開鍵情報をCloudFunctionsの環境変数として設定しておきます。

client_email及びprivate_keyはGCPの管理画面にてサービスアカウントを作成した際にダウンロード出来るjsonファイル内に記載されているものを設定します。
google_play_store_public_keyは Google Play Console 内の該当するアプリケーションの設定画面より確認出来ます。
「Google Play Console」 => 「開発ツール」 => 「サービスとAPI」と進み「このアプリのライセンス用鍵」に記載されているBase64エンコードのRSA公開鍵を設定します。

firebase functions:config:set env.client_email="EMAIL_ADDRESS"
firebase functions:config:set env.private_key="MIIBIjANBgkqh..."
firebase functions:config:set env.google_play_store_public_key="MIIBIjANBgkqh..."

 
次に検証処理を実装します。
Androidのレシート検証処理は下記のような流れとなります。

import * as functions from 'firebase-functions';
import * as crypto    from 'crypto';
import { google }     from 'googleapis';

const PACKAGE_NAME = 'com.example.yourAppIdentifier';

const authClient = new google.auth.JWT({
  email:  functions.config().env.client_email,
  key:    getPrivateKey(functions.config().env.private_key),
  scopes: ['https://www.googleapis.com/auth/androidpublisher']
});

const playDeveloperApiClient = google.androidpublisher({
  version: 'v3',
  auth:    authClient
});

function getPrivateKey(privateKey: string): string
{
  const key  = chunkSplit(privateKey, 64, '\n');
  const pkey = '-----BEGIN PRIVATE KEY-----\n' + key + '-----END PRIVATE KEY-----\n';

  return pkey;
}

function getPublicKey(publicKey: string): string
{
  const key  = chunkSplit(publicKey, 64, '\n');
  const pkey = '-----BEGIN PUBLIC KEY-----\n' + key + '-----END PUBLIC KEY-----\n';

  return pkey;
}

function chunkSplit(str: string, len: number, end: string): string
{
  const match = str.match(new RegExp('.{0,' + len + '}', 'g'));
  if (!match) {
    return '';
  }

  return match.join(end);
}

export const verifyReceiptForAndroid = functions.https.onRequest(async (req, res) => {

  if (req.method !== 'POST') {
    res.status(403).send();
    return;
  }

  const body      = req.body;
  const signature = body.signature;
  const receipt   = body.receipt;

  // 送信されたレシートの正当性を検証します。
  // レシートの署名データ(signature)はレシートデータ(receipt)をアプリのRSA公開鍵にてハッシュ化した物になっているはずなので、
  // 実際にreceiptを公開鍵にてハッシュ化し、signatureと一致するかを確認します。
  const validator = crypto.createVerify('SHA1');
  validator.update(receipt);

  let validity = false;
  try {
    validity = validator.verify(getPublicKey(functions.config().env.google_play_store_public_key), signature, 'base64');
  } catch (error) {
    res.status(500).send();
    return;
  }

  // レシートの正当性が確認できなかった場合、403を返却します。
  if (!validity) {
    res.status(403).send();
    return;
  }

  // 正当性が確認できたレシートは Cloud Firestore 等のストレージへ保存します。
  // `saveReceipt()`の詳細な実装は割愛します。
  const decodedReceipt = JSON.parse(receipt);
  await saveReceipt(decodedReceipt);

  // autoRenewing = true の場合は定期購読タイプのレシートと判定出来るので、有効期限が切れていないかを確認します。
  const typeOfSubscription = decodedReceipt['autoRenewing'];
  if (typeOfSubscription) {

    // 最新の購読情報を取得します。
    const response = await playDeveloperApiClient.purchases.subscriptions.get({
      packageName:    PACKAGE_NAME,
      subscriptionId: decodedReceipt['productId'],
      token:          decodedReceipt['purchaseToken']
    });

    // 最新の購読情報の有効期限を確認し、期限切れである場合はレシートの検証は失敗とし403を返却します。
    const subscriptions = response.data;
    if (subscriptions && subscriptions['expiryTimeMillis']) {
      if (+subscriptions['expiryTimeMillis'] <= Date.now()) {
        res.status(403).send();
        return;
      }
    }

    // 最新の購読情報の取得に失敗した場合も同様に検証失敗とし403を返却します。
    if (!subscriptions || response.status !== 200) {
      res.status(403).send();
      return;
    }

    // 必要に応じて購読情報もストレージへ保存します。
    // 実装は割愛します。
    await saveSubscriptionData(subscriptions);

  } else {

    // 消耗、非消耗タイプのレシートである場合はレシートが無効化されていないかを確認します。
    // Google Play は一定期間内であれば購入後のアイテムを返品することが可能なので、
    // 送信されたレシートが無効化(返品)されていないかを確認する必要があります。
    const response = await playDeveloperApiClient.purchases.voidedpurchases.list({
      packageName: PACKAGE_NAME,
    });

    // voidedPurchases(無効化された購入)に、検証対象のレシートが含まれていないかを確認します。
    const voidedPurchases = response.data;
    if (voidedPurchases && Array.isArray(voidedPurchases['voidedPurchases'])) {
      const voidedTokens = voidedPurchases['voidedPurchases'].map((purchase: any) => purchase['purchaseToken']);
      if (voidedTokens.includes(decodedReceipt['purchaseToken'])) {
        res.status(403).send();
        return;
      }
    }
  }

  // 全ての検証処理を通過した場合のみ、200を返却します。
  res.status(200).send(JSON.stringify({
    message: 'Receipt verification successfully.',
  }));
});

 

iOSの場合の実装サンプル

アプリ側

前項にて実装した_verifyPurchase()を更に拡張します。

Future<bool> verifyPurchase(PurchaseDetails purchaseDetails) async
{
  if (Platform.isAndroid) {

    ...

  }
  else if (Platform.isIOS) {
    try {
      // 購入完了時、Base64エンコードされたレシートデータを受け取るので、それを検証用のエンドポイントへ送信します。
      // `purchaseDetails.verificationData.localVerificationData`にBase64エンコードされたレシートデータが格納されています。
      final body = json.encode({
        'data': purchaseDetails.verificationData.localVerificationData,
      })

      /// `RECEIPT_VERIFICATION_ENDPOINT_FOR_IOS`にはCloudFunctionsのエンドポイントが設定されている想定です。
      /// データをレシート検証用エンドポイントに送信し、ステータスコード200が返却されれば検証は完了です。
      /// また、レシートデータの検証が正常に完了すると、エンドポイントからはそのアプリ内での有料アイテム購入時のトランザクションが全て返却されるので、
      /// その内容(商品名、有効期限等)をアプリケーション側で適宜判定し、サービスを提供するように実装します。
      /// 200以外のステータスコードを受信した場合、`catch`にて補足され即座に`false`が返却されます。
      final response = await http.post(RECEIPT_VERIFICATION_ENDPOINT_FOR_IOS, body: body);
      final decoded = json.decode(response.body);
      final transactions = decoded['transactions'];

      /// 以下、取得した`transactions`を検証して適宜サービスを提供出来るよう実装して下さい。

    } catch (e) {
      return false;
    }
  }

  return true;
}

 

サーバー側

iOSのレシート検証用エンドポイントにもCloudFunctionsを利用します。
iOSにおけるレシート検証は、本番環境用とサンドボックス環境用の2つのAPIが用意されています。

  • 本番環境用
    • https://buy.itunes.apple.com/verifyReceipt
  • サンドボックス用
    • https://sandbox.itunes.apple.com/verifyReceipt

 
上記APIに対して、下記構造のJSONをポストすることでレシートの検証を行います。
passwordreciept-dataに定期購読タイプのレシートデータを含む場合、必須となります。
exclude-old-transactionstrueを指定した場合、各定期購読アイテムそれぞれの最新のレシート1件のみを返却してくれます。
falseを指定した場合は全てが返却されます。

{
  "receipt-data": "Base64エンコードされたレシートデータ",
  "password": "App用共有シークレット",
  "exclude-old-transactions": true // or false
}

 
また「App用共有シークレット」は「App Store Connect」の下記箇所にて確認することが出来ます。


 
具体的な実装は下記の様になります。

import * as functions from 'firebase-functions';
import axios          from 'axios';

const RECEIPT_VERIFICATION_ENDPOINT_FOR_IOS_SANDBOX = 'https://sandbox.itunes.apple.com/verifyReceipt';
const RECEIPT_VERIFICATION_ENDPOINT_FOR_IOS_PROD    = 'https://buy.itunes.apple.com/verifyReceipt';
const RECEIPT_VERIFICATION_PASSWORD_FOR_IOS         = 'APP_SHARED_SECRET_HERE';
const PACKAGE_NAME                                  = 'com.example.yourAppIdentifier';

export const verifyReceiptForIOs = functions.https.onRequest(async (req, res) => {

  if (req.method !== 'POST') {
    res.status(403).send();
    return;
  }

  const body             = req.body;
  const verificationData = body.data;

  if (!verificationData) {
    res.status(403).send();
    return;
  }

  let response;
  try {
    // レシート検証はまず、本番用APIにデータを送信します。
    response = await axios.post(RECEIPT_VERIFICATION_ENDPOINT_FOR_IOS_PROD, {
      'receipt-data':             verificationData,
      'password':                 RECEIPT_VERIFICATION_PASSWORD_FOR_IOS,
      'exclude-old-transactions': true,
    });

    // 本番用APIから返却された`status`が`21007`の場合、送信されたレシートがサンドボックス環境用のものであることを示しているため、
    // サンドボックス用のAPIにリクエストを送信し直します。
    if (response.data && response.data['status'] == 21007) {
      response = await axios.post(RECEIPT_VERIFICATION_ENDPOINT_FOR_IOS_SANDBOX, {
        'receipt-data':             verificationData,
        'password':                 RECEIPT_VERIFICATION_PASSWORD_FOR_IOS,
        'exclude-old-transactions': true,
      });
    }
  } catch (e) {
    res.status(400).send();
    return;
  }

  // レシート検証用APIから返却されたレスポンス内の`status`が`0`であれば検証は成功です。
  // それ以外の数値が返却されている場合は失敗となります。
  // 全ステータスコードの詳細は下記URLを参照下さい。
  // https://developer.apple.com/documentation/appstorereceipts/status#possible-values
  const result = response.data;
  if (result['status'] != 0) {
    res.status(403).send();
    return;
  }

  // レスポンスデータ内の`bundle_id`が自身のパッケージ名と一致しているか確認します。
  if (!result['receipt'] || result['receipt']['bundle_id'] != PACKAGE_NAME) {
    res.status(403).send();
    return;
  }

  // `receipt.in_app`には`receipt-data`を解析して得られた購入履歴が格納されています。
  const receiptCollections: Array<object> = result['receipt']['in_app'];
  if (result['latest_receipt_info']) {
    // `latest_receipt_info`は定期購読タイプのアイテムを購入したことがある場合のみ存在しています。
    // 送信された`receipt-data`に紐づくAppleアカウントから行われた、このアプリに関する全てのアイテムの購入履歴が格納されています。
    // 定期購読タイプのアイテムを1度でも購入したことがある場合、それ以外のアイテムの購入履歴もここに含まれる様になります。
    // ここでは`latest_receipt_info`が存在していた場合、`receipt.in_app`に含まれていた購入履歴とマージして返却しています。
    receiptCollections.concat(...result['latest_receipt_info']);
  }

  // 得られた全ての購入履歴を必要に応じて保存します。
  // `saveTransactionIOs()`の実装は割愛します。
  for (let transaction of receiptCollections) {
    await saveTransactionIOs(transaction);
  }

  // レシートの検証処理が正常に完了したので、得られた全ての購入履歴と共に200を返却します。
  res.status(200).send(JSON.stringify({
    message: 'Receipt registration successfully.',
    transactions: receiptCollections
  }));
});

 
以上でAndroid、iOS双方におけるサーバーサイドレシート検証処理の実装は完了です。
 

おわりに

Flutterではin_app_purchaseを利用することにより、簡単にIAPを実装することが出来るようになりましたが、レシートの正当性検証に関しては各プラットフォーム毎のルール、仕様をしっかりと把握した上で実装していく必要があります。
アプリケーションがリバースエンジニアリングを受けた際、不正なレシートを弾くための機構が備わっていないと、甚大な損失を被ってしまう可能性があります。
アプリケーションへの攻撃から利用者、ひいては開発者を守るためにも、課金周りの処理はより一層慎重に実装し、安全なアプリケーションの提供を心がけましょう。