【Google】アプリ内アイテムの購入と消費状態の取得をPHPとKotlinで実現する
スマホのアプリ内課金は、ゲームのポイントのような都度課金(都度決済)や、音楽聞き放題のような月額のサブスクリプション(定期購読、継続課金)含め当たり前の機能となってきました。
スマホを使って有料のサービスを受けたことがある人なら、以下のいずれかを利用している可能性は高いのではないでしょうか。
・Google(Play Billing)
・Apple(Inapp Purchase)
・Amazon(Inapp Purchase)
課金する側であればスマホを操作するだけで難しいことはないのですが、サービスを提供する側になると色々と悩ましい問題が発生します。
今回は、アプリ内課金の実装について調べる機会があったので、特に Google(Android)の仕様についてまとめておきたいと思います。
アプリ内課金の流れ
アプリ内課金の流れは Google と Apple で大きな違いはなく、ネイティブアプリでの決済だけであればそれほど苦労することはありません。
例えば Google だと、以下にネイティブアプリの実装で使用するフローや API についてのドキュメントが用意されています。
ネイティブアプリ単体であれば、ストア(GooglePlay Store)の商品に対して「決済」や「消費」をすることは Android 開発者なら朝飯前なのかもしれませんね。
よって今回はネイティブアプリではなく、ネイティブで決済した後のレシート情報をサーバサイドで検証する部分にフォーカスしてみます。
Google Play Developer には、アプリ内アイテムの購入と消費状態を取得する API が用意されています。購入レシートの情報を元に参照することから、レシート検証と言ったりすることもあります。
サーバサイドで購入状態を確認する必要性
ネイティブアプリでアプリ内決済した際、ストアから決済レシートが発行されます。
多くのサービスでは、レシートの内容(例えば購入した商品ID)を参考にポイントなど何かしらのインセンティブをユーザに付与すると思います。
ただしネイティブアプリ側で発行されたレシートの内容を鵜呑みにしてしまうと、レシートの改ざんなどセキュリティ面のリスクがあるため、サーバサイドから Google の Purchase API を使って購入状態を確認するのが一般的になっています。
・アプリで決済する
・レシートと署名が発行される
・レシートと署名をサーバサイドへ送信
・サーバサイドで署名の整合性を確認
・サーバサイドでレシートの整合性を確認
・サーバサイドから Purchase API でレシート確認
・API で返されたレシートの整合性を確認
・ユーザへポイントなどを付与
・アプリで決済ステータスを消費にする
ただし、サーバサイドでレシート検証を実装する際に気をつけたいポイントが大きく 3 つあります。
・アプリから送信された情報のチェック
・Purchase API とのやり取り
・重複課金を防ぐ仕組み
この手の実装を初めて行う際は、未知な部分が多くて、ネイティブアプリと結合するまで不安です。
そこでハマりそうなポイントを 1 つずつ見ていきたいと思います。
アプリから送信された情報のチェック
ストアの商品を購入すると、ネイティブアプリ側でレシートと署名の情報が取得できます。
レシート情報は JSON 文字列のデータですが、署名は Base64 エンコードされた長い文字列になります。
サーバサイドではまず、この署名がアプリから正しく送信されてきたものかどうかをチェックします。必要な情報は以下の通り。
・レシートの情報(JSON)
・署名(文字列)
・アプリの公開鍵
「アプリの公開鍵」は GooglePlay Console 上でアプリごとに管理されているものになります。
要は、これらの情報を使ってレシートの SHA1 ハッシュ値と公開鍵で復号化した署名を照合するわけです。
これを PHP で書くと以下のようなイメージ。
$receiptJson = '{"orderId":"xxxxxx","productId":"xxxxxx",...}';
$signature = base64_decode('xxxxxxxxxx');
// 下記のURLなどを参考にpem形式に変換
// FYI: http://php.net/manual/ja/ref.openssl.php
$publicKey = der2pem(base64_decode('xxxxxxxxxx'));
if (openssl_verify($receiptJson, $signature, openssl_get_publickey($publicKey)) === 1) {
// 正しい
}
せっかく Android が絡むので、サーバサイド側の署名チェックも Kotlin で書いてみましょうか。
必要な部分だけ抜粋してエラー処理は省きます。
val receiptJson = "{¥"orderId¥":¥"xxxxxx¥",¥"productId¥":¥"xxxxxx¥",...}"
val signature = base64_decode("xxxxxxxxxx")
val decodedPublicKey = Base64.decodeBase64("xxxxxxxxxx")
val publickKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(decodedPublicKey))
val decSignature = java.security.Signature.getInstance("SHA1withRSA")
decSignature.initVerify(publicKey)
decSignature.update(receiptJson.toByteArray())
if (decSignature.verify(Base64.decodeBase64(signature))) {
// 正しい
}
これで署名が正しいことが確認できれば次のステップです。
Purchase API とのやり取り
次に、レシートの情報を Google の Purchase API から取得して、アプリから送られてきたレシートとの整合性を確認します。
Purchase API のエンドポイントとリクエスト・レスポンスの仕様は以下の通りなので、まずは API を叩くのに必要な情報を揃えておきます。
エンドポイントの URL パスには以下の 3 つの情報を埋め込みます。全てレシート情報(JSON)に記載されている値です。
https://www.googleapis.com/androidpublisher/v3/applications/[packageName]/purchases/products/[productId]/tokens/[token]
packageName: アプリのパッケージ名
productId: 商品ID
token: トークン
ただし、この API を実行するには、Google のクライアント認証でアクセストークンを取得しておく必要があります。
以下のドキュメントを参考にしてもらうとわかりますが、OAuth の API を叩いて取得します。
Scope: https://www.googleapis.com/auth/androidpublisher
必要に応じてリフレッシュトークンも使用しますが、面倒であればライブラリに任せてしまうのも手ですね。
Gradle を使っている場合は、build.gradle の dependencies に以下の定義を追加します。
implementation('com.google.api-client:google-api-client:1.25.0')
compile ではなく implementation を使いましょうって話ですね。
プログラム的にはこれでいいのですが、この API を利用するには Google の「サービスアカウント」を作成して、アカウント作成時に発行される認証ファイル(JSONまたはp12)を取得しておく必要があります。
GooglePlay Console 上のネイティブアプリ一覧画面にアクセスすると、左メニューに「設定」がありますので、ここからサービスアカウントの発行や API の利用許可をすることができます。
ここまで準備できたら、Google のクライアント認証で取得したアクセストークンを Purchase API の URL にリクエストヘッダとして付与します。
https://www.googleapis.com/androidpublisher/v3/applications/[packageName]/purchases/products/[productId]/tokens/[token]
各パラメータに問題がある場合は 400、認証情報に問題がある場合は 401 など、Google からレスポンスのステータスコードとエラーが返されますが、このあたりの詳細な仕様はどこにも記載されていないのでもどかしいところです。
API のリクエストに問題がなければ、レシート情報が返ってきますので必要事項を確認して整合性チェックを完了します。
・テスト購入判定
・アプリから送信されたレシートとの照合
・消費されていないかの確認
レスポンスの purchaseType の有無で、テスト購入かどうかも判断できるので、この API のチェックは必ずやっておきたいところです。
また記事の最後に追記しましたが、2019 年の 5 月上旬から、レスポンスに「acknowledgementState」が追加されています。同じくして、レシートデータにも「acknowledged」が増えています。
{
"kind": string,
"purchaseTimeMillis": string,
"purchaseState": integer,
"consumptionState": integer,
"developerPayload": string,
"orderId": string,
"purchaseType": integer,
"acknowledgementState": integer,
"purchaseToken": string,
"productId": string,
"quantity": integer,
"obfuscatedExternalAccountId": string,
"obfuscatedExternalProfileId": string,
"regionCode": string
}
2020 年の後半から、署名チェックについての記載が公式ドキュメントからなくなりました。ページ内検索では以下の文言が引っ掛かるのですが、リンクの遷移先は「不正行為や不正使用に対処する」のページへのリダイレクトになっています。
セキュリティに関するベスト プラクティス | Android デベロッパー
2020年6月9日 … サーバー上に署名検証ロジックを実装することで、攻撃者が APK ファイルを リバース エンジニアリングするのを困難にすることができます。これにより、 ロジックでチェックする署名の整合性が保たれます。 信頼できる
2023 年現在も、この署名チェックについてのドキュメントが公式サイトから削除されているようです。不要になったのか経緯がわからないままなので、情報をお持ちの方はぜひ教えてください。
重複課金を防ぐ
重複課金をどのように制御するかは、プロジェクトの実装や運用方法によって方針が異なると思います。
私は、テスト購入の際には「注文ID(OrderId)」がレシート情報に入ってこないという仕様を読んでいたので、purchaseToken を活用して一意性を給う方向で検討していたのですが、テストアプリで試したところ OrderId は含まれていました。
よってアプリ単位で、同じ OrderId のレシートを重複して許容しなければ良さそうです。
(アプリに関係なく OrderId はユニークになっていると思いますが)
しかし Google のドキュメントには、「テスト購入(サンドボックス)では OrderId が情報として入ってこないので注意しろ」と書いてあるのですが、実際にはテスト購入時にも OrderId は入ってきているのですよね。
これは純粋に、Google のドキュメントが古い可能性がありますがどうなんでしょうか。
注: テスト購入には orderId フィールドがありません。テストトランザクションをトラックするには、purchaseToken フィールドを使用してください。
しかし 2020 年後半に、ドキュメントに以下の記載が追加されました。
現在の購入の purchaseToken 値が以前のどの purchaseToken 値とも一致しないことを確認します。purchaseToken はグローバルに一意であるため、この値はデータベースの主キーとして安全に使用できます。
注: すべての購入で orderId が生成されるとは限らないため、orderId を重複購入のチェックに使用したり、データベースの主キーとして使用したりしないでください。特に、プロモーション コードを使用した購入では、orderId は生成されません。
残念ながらページ構成が変わってしまい、以前のドキュメントの内容と比較ができないのですが、とにかく purchaseToken で一意性をチェックしろということですね。
下の方でも書いていますが、purchaseToken は 180 文字くらいと認識しているのですが、文字数については公式に最大文字数が仕様として定義されていません。主キーとして扱うには微妙な長さですね。