こんにちは!
システム開発部のK.Mです。
iOS、Androidの各プラットフォームに用意されている自動更新のサブスクリプション(以下、サブスク)に関して、調査のため実装を行い、サーバ部分に関して得た知見を記載していきます。
前置き
近年、アプリゲームにて自動更新はされないが、購入すれば30日間ログイン時にガチャ用の石が一定数貰える+αという定期購入的な課金アイテムが増えてきております。
ただ、このような課金アイテムは各プラットフォームが用意している自動更新のサブスクを使わずに通常の課金アイテム扱いとして、ゲームサーバ側で30日間のチェックを行い、30日間経過したら無効状態にし、またユーザーに購入してもらうという形となっていたりするものが多いです。
この部分を各プラットフォームが用意している自動更新のサブスク機能を使って実装すれば、ユーザーは毎回購入する必要は無く、一度購入すれば自発的に解約しない限り毎月自動で更新する形が実装できます。
(提供者側からすると売上の予想が立てやすくなるメリットがあります。)
ただ、自動更新のサブスクに関しては通常の課金アイテムとは異なる実装が必要になってきます。
ここでは実際に自動更新のサブスクをどのように実装したかを、主にサーバ観点での情報を記載しております。
概要
Unityを使った実装を想定しており、クライアント(アプリ)側はUnityIAPで処理を行い、サーバ側の下記部分に関しての内容に言及しています。
- サブスクのレシート検証
- サブスクの自動更新方法
また、AppStoreConnectとGooglePlayConsoleにてサブスクのアイテムは登録済を前提としております。
iOS、Android共にサブスクで決まった期間、例えば1ヶ月分のサブスクを購入し1ヶ月後の更新タイミングにて更新処理をする必要があるのですが、クライアントのみで対応するか・サーバにて対応するかの2パターンがあります。
今回記載している内容はサーバにて更新するケースとなります。
クライアントのみで更新する場合に考えられるのは
- 毎回の起動時に各プラットフォームのAPIへアクセスしてサブスク有効かを確認
- 無効ならサブスク解除
- 有効ならサブスク更新
または
- サブスク購入後、1ヶ月以上経過した後の初回起動時に各プラットフォームのAPIへアクセスしてサブスク有効かを確認
- 無効ならサブスク解除
- 有効ならサブスク更新
が考えられますが、前者に関しては
- 起動するたびに各プラットフォームにアクセスするため無駄な通信が発生する
- プラットフォームによってはAPIのアクセス回数が制限されている
- Googleの割り当て説明
- Appleは制限は無さそう?
という難点があり、後者に関しては
- 時間の判定を端末内の時計で判断していると、端末の時間を変更して更新処理を回避される
- サーバから日時を取得して判断すれば対応自体は可能だがメモリや通信を弄られると対策が難しい
という難点があるため、クライアントのみでサブスクの更新を行うのは少々リスクが伴います。
そのため、サブスクの更新処理はサーバで行うのが安全かと考えられます。
(現時点で自動更新のサブスクを使わずに通常の課金アイテムでサブスクっぽい動きをするものが多いのは、実装・テストが大変なのとセキュリティ的な問題でゲームアプリでは使用しているものが少ないのでは?というのが個人的な所感です。)
共通処理
サーバにてiosやandroidなどの各プラットフォーム関係無く共通した処理を行う部分があるので、その点についての流れが下記になります。
- クライアントアプリからUnityIAPにて購入した商品情報(レシート含む)をサーバへAPIなどを使用して通知する
- サーバは通知された購入情報にあるレシートの情報を使って各プラットフォームのサーバへ通信して、この購入内容が正しいものかを判断する
- 正しいものと判断出来れば、アイテムなどを付与する
- 正しくなければエラー処理を実施
- 結果をクライアントアプリに返す
- サーバにてサーバ通知の機能を用意して更新・解約時の処理を行う
この辺りは課金での基本的な処理なのでよくある流れになりますが、サブスクだとここに5番目の処理にサーバ通知という処理が入ってきます。
という名前で機能が用意されています。
サーバ通知はサブスクの購入時や更新時、情報変更時やキャンセル時などに通知されてくる処理となります。
各プラットフォームでの詳しい説明を下記で行います。
ios(AppStoreサーバ通知)
設定方法
AppStoreConnectのアプリ設定画面から一般→App情報内にある、「AppStoreサーバ通知」にて、Appleからの通知を受け取るURLを設定します。
設定するURLですが、プロダクションサーバURLとSandboxサーバURLの2箇所設定出来るようになっており、用途に応じて設定する場所を変える必要があります。
本番環境用がプロダクションサーバURLで、開発環境用がSandboxサーバURLとなります。
上記の入力画面にて、2021年の後半頃にAppStoreサーバ通知にVer2という新しいバージョンが追加されておりますが、現状日本語での文献が少なく、Ver2にある細かい状態変化による通知を受け取って処理したいということが無ければ、Ver1を選択するのは現時点での選択肢としてはアリだと考えられます。
今回の内容にてVer2について触れておりますが、正常系での確認しかできておらず、返金などでどのような通知が来るのかは未確認となっております。
サーバでの処理方法
初回購入時のレシートチェック
サーバ通知ではゲーム内で使用するユーザーIDのようなものは通知されないので、サブスクの初回購入時のレシートチェックする際にユーザーとサブスクを紐付ける固有のIDを保存しておく必要があります。
appleの場合ですと、レシート内にあるoriginal_transaction_id
が初回購入時のサブスクを判断するものになるので、original_transaction_id
とユーザーIDを紐付けるデータをデータベースなどに保存しておく必要があります。
また、通常の課金アイテムと異なりレシート検証時には共有シークレットを渡す必要があるので注意が必要となります。
参考リンク
バージョン1のサーバ通知
バージョン1の場合にサーバ通知で設定したURLに通知されてくる情報は[こちらの内容] (https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv1)になります。
重要な項目は下記のようになります。
- original_transaction_id
- 初回サブスクのtransaction_idになるので、ユーザーIDと紐付けしている場合にこのIDからどのユーザーの情報かを特定するために使います
- notification_type
- サーバ通知が来た理由が設定されているので、この値に応じて処理を分ける必要があります
- unified_receipt
- 更新時などであればここにレシート情報が入っています
バージョン1単体での問題点としてサーバ通知してきたのが、本当にappleからの通知なのかの検証方法がない点です。
そのため、上記に記載した共有シークレットを使う必要があるため、サーバ通知を受け取ってから再度レシート検証のAPIを叩いて取得した情報に整合性があれば、appleからの通知だったと担保が取れるのでセキュリティ的に問題無いという状況が作れます。
またセキュリティ考慮のためか、バージョン2ではJWS(JWT)を使った方法が導入されています。
バージョン2のサーバ通知
バージョン2だと下記のようなデータでサーバ通知されてきます。
{ signedPayload: 'eyJhb...(以下長いので省略' }
JWSなので、一先ず.区切りでbase64化されたヘッダー情報、ペイロード情報(データ)、鍵情報が入っているのでbase64でデコードすればデータ自体は取得可能です。
base64デコードしたペイロード情報が下記のようになっているので、notificationType毎に処理を行えば問題無いです。
ヘッダー情報
{ "alg": "ES256", "x5c": [ "MIIEMD...省略", "MIIDFj...省略", "MIICQz...省略" ] }
ペイロード情報
{ "notificationType": "SUBSCRIBED", "subtype": "RESUBSCRIBE", "notificationUUID": "318332e9-8947-4fba-a57c-b287b52ac4be", "data": { "bundleId": "パッケージ名", "bundleVersion": "0", "environment": "Sandbox", "signedTransactionInfo": "eyJhbGc...省略", "signedRenewalInfo": "eyJhbGc...省略" }, "version": "2.0" }
ペイロード情報に入っている、signedTransactionInfo
とsignedRenewalInfo
もJWS形式なので同じように.区切りでデータを取ってみると下記のような情報になっており、こちらの情報を使いつつユーザーのサブスクの情報を更新する必要があります。
signedTransactionInfoのペイロード情報
{ "transactionId": "1000000961669498", "originalTransactionId": "1000000934880850", "webOrderLineItemId": "1000000072068129", "bundleId": "パッケージ名", "productId": "商品ID", "subscriptionGroupIdentifier": "20908926", "purchaseDate": 1643868828000, "originalPurchaseDate": 1640074890000, "expiresDate": 1643869128000, "quantity": 1, "type": "Auto-Renewable Subscription", "inAppOwnershipType": "PURCHASED", "signedDate": 1643868865974 }
signedRenewalInfoのペイロード情報
{ "originalTransactionId": "1000000934880850", "autoRenewProductId": "商品ID", "productId": "商品ID", "autoRenewStatus": 1, "signedDate": 1643868865950 }
.区切りでbase64デコードすれば必要なデータ自体は取れてしまうのですが、改ざんなどのセキュリティ観点で鍵の検証はやっておく必要があります。
鍵の検証方法
自分が検証していた時期が、2021年12月〜2022年1月辺りだったためその当時だと日本語の情報がほとんど無く、今現在だとmixiさんなど、他の方からも情報が出てきている状況ですが、自分が検証した際の手順を記載しております。
一先ずJWSとして、ヘッダー情報にある内容から検証する必要があるのですが、SignInWithAppleだとJWTであったので今回もkid
使って検証するのかなと思いきや、JWSでは異なり、algにES256が設定されていて、x5c(X.509)の証明書をチェーン検証する形になっていました。
チェーン検証が必要なため、x5cが3つ設定されているので順番に検証していくのですが、最後のルート証明書がどこのどれを使うべきなのかの情報が全く無く困っていました。
色々検索してappleのQ&Aでルート証明書のありか自体は分かったのですが、4つあり、どれが正解のルート証明書なのかが不明でした。
分からないのなら、一つずつ検証するしかないかと一つずつ検証したところエラーが出ずに問題が出なかったのが、AppleRootCA-G3.cer
でした。mixiさんの記事にあるような各証明書の中身を見ることをしなかったですが、結果的に合っていたようでした。
確認時のnodeのソースコードですが、JWSの鍵検証の実装例は下記のようになります。
signedPayloadのデータを直接入れ込んだ一例です。
signedTransactionInfoやsignedRenewalInfoも同じような検証で確認可能です。
nodeのjsrsasignを使って検証しています。
const jsrsasign = require('jsrsasign'); const signedPayload = 'eyJh...'; const jwsData = signedPayload.split('.'); const header = new Buffer.from(jwsData[0], 'base64').toString(); const obj1 = JSON.parse(header); // チェーン数に変更があったら要注意 // 証明書のフォーマットにする x5c1 = obj1.x5c[0]; x5c1 = x5c1.match(/.{1,64}/g).join('\n'); x5c1 = `-----BEGIN CERTIFICATE-----\n${x5c1}\n-----END CERTIFICATE-----\n`; x5c2 = obj1.x5c[1]; x5c2 = x5c2.match(/.{1,64}/g).join('\n'); x5c2 = `-----BEGIN CERTIFICATE-----\n${x5c2}\n-----END CERTIFICATE-----\n`; x5c3 = obj1.x5c[2]; x5c3 = x5c3.match(/.{1,64}/g).join('\n'); x5c3 = `-----BEGIN CERTIFICATE-----\n${x5c3}\n-----END CERTIFICATE-----\n`; // appleのルート証明書 const certRootStr = fs.readFileSync("./key/AppleRootCA-G3.pem", 'utf-8'); // チェーンを順番に設定 const pemCertificateChain = [ x5c1, x5c2, x5c3, certRootStr ]; let valid = true; // 一つずつチェーンの検証 for(let i = 0; i < pemCertificateChain.length; i++) { let Cert = pemCertificateChain[i]; let certificate = new jsrsasign.X509(); certificate.readCertPEM(Cert); let CACert = ''; if(i + 1 >= pemCertificateChain.length) { CACert = Cert; } else { CACert = pemCertificateChain[i + 1]; } let certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex, 0, [0]); let algorithm = certificate.getSignatureAlgorithmField(); let signatureHex = certificate.getSignatureValueHex() // Verify against CA let Signature = new jsrsasign.crypto.Signature({alg: algorithm}); Signature.init(CACert); Signature.updateHex(certStruct); try { // true if CA signed the certificate valid = valid && Signature.verify(signatureHex); }catch(e) { // エラー処理 console.log(e); } } // この後にvalidがtrueだったらOK、falseだったら検証エラーの処理をする
android(リアルタイムデベロッパー通知)
設定方法
androidはiosと比べると少し設定が複雑になっています。
- GoogleCloudPlatformにてPub/Sub機能を設定する
- 上記の設定したPub/Subの情報をGooglePlayConsoleで設定する
という流れになります。
Cloud Pub/Sub設定
GoogleCloudPlatformのナビゲーションメニューを選択し、分析 →Pub/Subを選択するとPub/Subの画面が表示されるので「トピックを作成」を選択する
選択するとトピックのID入力画面が表示されるのでトピックIDを入力して「トピックを作成」を選択する。この際、デフォルトのサブスクを追加するのチェックがONになっているので、OFFにしておきます
トピックが作成されるのでトピック一覧から作成したトピックIDを選択してトピックの詳細画面を表示する
トピックの詳細画面をスクロールすると下の方にサブスクという項目があるので、「サブスクリプションを作成」というのがあるのでこれを選択する
選択して表示されるボックスから「サブスクリプションを作成」を選択する
サブスクリプションの作成画面になるので任意のサブスクリプションIDを入力して、配信方法でpush選択するとエンドポイントURLが表示されるので用意しているURLを入力する。他は任意の設定(基本デフォルトのままで問題無いはずです)一番下までスクロールしてCREATEボタンを押す
トピックに紐付いたサブスクリプションが作成されるので、トピック一覧から使用するトピックの名前をGooglePlayConsoleの設定で使用するのでコピーしておきます
GooglePlayConsole設定
GooglePlayConsoleのメニューにある収益化→収益化のセットアップを選択して、収益化のセットアップの設定画面を表示して「リアルタイム デベロッパー通知」のトピック名にコピーしていたPub/Subのトピック名を入力する
トピック名を入力したら、入力欄すぐ下にある「テスト通知を送信」のリンクを選択すると、テスト通知が設定したPush先のURLに通知されます。下記のようなデータ通知されてきていれば問題無く設定出来ている状態となります
{ message: { data: 'base64でエンコードされたデータ', messageId: '3838595185971385', message_id: '3838595185971385', publishTime: '2022-06-10T08:51:33.99Z', publish_time: '2022-06-10T08:51:33.99Z' }, subscription: 'projects/XXXXXXXXXXXX/subscriptions/sub' }
dataの中身
{ version: '1.0', packageName: 'パッケージ名', eventTimeMillis: '1654818693891', testNotification: { version: '1.0' } }
実際のサブスクで通知されてくるデータは異なってきます(dataの中身にサブスク用のデータが入ってくる)
サーバでの処理方法
初回購入時のレシートチェック
Androidでも同じく、サーバ通知ではゲーム内で使用するユーザーIDのようなものは通知されないのでレシートチェック時にユーザーとサブスクを紐付ける固有のIDを保存しておく必要があります。
Androidであれば、purchaseToken
がサブスク内の一意なIDになっているのでpurchaseToken
とユーザーIDを紐付けて保存しておく必要があります。
サーバ通知処理
GoogleCloudPlatformで設定したPub/Subのサブスクリプション設定に登録したPush先のURLにて通知を受け取ったら適切に処理を行う実装を行う必要があります。
ユーザーのサブスクにて何かしらの変更が発生(更新やキャンセル)した場合に下記のような通知が来ます。
message配下のdataの中身 { "version":"1.0", "packageName":"パッケージ名", "eventTimeMillis":"1654818693891", "subscriptionNotification":{ "version":"1.0", "notificationType":4, "purchaseToken":"サブスク更新時のトークン", "subscriptionId":"サブスクの課金アイテムID" } }
念のため、subscriptionとdata内のpackageNameが自アプリのものと一致しているかのチェックを行います。
セキュリティの観点からサーバ通知から情報受け取った後に、AndroidのDevelopAPIを使って情報を取得して、サーバ通知との整合性があっていれば期限を更新する形にします。
使うAPIはサブスクの情報をGetするAPIになりますが、OAuthのトークンも必要になるので、
- https://oauth2.googleapis.com/token でOAuthトークンを取得する
- 取得したOAuthトークンとサーバ通知で貰った'purchaseToken'と'subscriptionId'でDevelopAPIから情報を取得する
- サーバ通知で貰った'notificationType'の値とDevelopAPIで取得した情報と現時点でのユーザのサブスクの情報に整合性あれは情報更新を行う
notificationTypeごとの処理
subscriptionNotification内にあるnotificationTypeに更新などの状態が設定されてきているので、この値に沿って実装する必要があります。
各値の詳細な状態に関しては、こちらに記載がありますので、対応方法としては分かりやすいです。
今後の課題
今回の実装が研究開発レベルでの確認のため、下記が今後の課題だと考えております。
- 本番環境での確認が出来ていないため100万人規模のユーザー数だと、どれぐらいのユーザーがサブスクを購入するのか?
- 1万人単位で同じ時間帯にサブスクを購入することは無いだろうが、現実的な線で1000人単位で同じ時間帯に購入が発生した場合に、サーバ通知がどのような動きをするのかという負荷観点で気になる点がある。
- 単純にサーバ通知を受け取るサーバをスケールアップすれば問題無いのだろうが、各プラットフォームにて遅延したりしないのかが気になる。
- プラットフォーム側がバグってサーバ通知がされないとかもありそうなので、そこまで考慮する必要性があるのか?
リベル・エンタテインメントでは、このような最新技術などの取り組みに興味のある方を募集しています。
もしご興味を持たれましたら下記サイトにアクセスしてみてください。
https://liberent.co.jp/recruit/