iOSの月額課金レシート検証をサーバーサイドで行うときのTipsまとめ

最近、iOSのレシート検証をクライアントのみでの検証からサーバーサイドでの検証に実装を変更する機会があったので、その理由や方法、考慮するべき点などを書きました。参考になれば幸いです！

# 注意

この情報は2016年8月3日現在のものです。

# レシート検証とは？
iOSで月額課金をすると、課金の証明としてAppStoreがレシートを発行します。レシートと言ってもAppStoreが紙のレシートを送りつけてくるわけではなく、電子的な購入情報のことをレシートと呼びます。ユーザーが解約処理をしない限りAppStore側でレシートが自動更新される仕組みになっています。(月額課金の場合)</br>
その際に、AppStoreのサーバーにHTTPのPOSTリクエストでレシートを問い合わせ、現在の課金状況を知ることができます。このお問い合わせ処理と、レシートが不正なレシートでないかをチェックする処理を合わせてレシート検証と呼びます。</br>

# サーバーサイドでのレシート検証が推奨される理由
レシート検証は、クライアント側で完結させることもできます。しかし、クライアント側で完結させてしまうことで2つのデメリットが発生します。

1. クライアント側で検証処理が閉じているためユーザーによるレシート改ざんが可能になってしまう
2. 複数のプラットフォームで課金サービスを提供している際に、クロスデバイス対応が難しくなる

1に関して、JailBreakやroot化を使って不正レシートをサーバーサイドに送りつける例があるそうなので、サーバーサイドでのレシート検証が推奨されています。</br>
2に関しては、機種変更やデバイスの使い分けをしている方など、iOS/Android/Web等の複数プラットフォームで同じユーザーでログインする場合があります。その場合に、サーバーサイドに検証できる仕組みがあれば別端末にログインした場合でも、より正確な課金の有無の検証が可能になります。


# サーバーサイドでのレシート検証の方法
AppStoreのサーバーにHTTPのPOSTリクエストを送ることでレシートを問い合わせることができます。


### リクエストURL
テスト環境用のURLと、production用のURLで分かれています。

|環境|URL|用途|
|-----|-----|-----|
|production|https://buy.itunes.apple.com/verifyReceipt|本番用
|sandbox|https://sandbox.itunes.apple.com/verifyReceipt|開発時のテスト環境用

- sandboxにはsandboxの、productionにはproduction用のレシートがあり、productionのURLにsandbox用のレシートを送るとエラーが返ってきます。
- sandboxを利用するには、Appleのテストアカウントを取得して課金処理のテストを行います。


### リクエストbody
下記の2つだけでOKです。

|key|値|サンプル
|-----|-----|----|
|receipt-data|Base64エンコードしたレシート情報|MIIjwgYJKoZIhvcNAQcCoIIjszCCI...|
|password|アプリケーションの共有鍵|fea2ebde5...|


- receipt-dataのサンプルは省略してありますが、実際はかなり長いです。12KB程度のデータです。
- サーバーサイドでレシートをBase64エンコードするのではなく、クライアントかAppStoreからBase64エンコード済みのデータを受け取ります。一番最初の購入時に必ずクライアントからBase64エンコード済みのレシートを送ってもらわないとサーバーサイドとAppStoreで直接レシート検証のやり取りができません。

### 実装上の注意
- 審査が通るまではsandboxしか使えないのでproduction環境でもsandboxのレシートを受け付けられるような実装にしておく必要があります。
- rubyのレシートサーバーサイド検証用のgemに<a href='https://github.com/nomad/venice'>venice</a>というgemがありますが、AppStoreのAPIのバージョンによっては動きませんでした。gemを使う場合は、ご自分のバージョンで正常に動作をするかしっかり確認してから使うことをおすすめします。

<img width="550" alt="スクリーンショット 2016-07-30 16.39.15.png" src="https://qiita-image-store.s3.amazonaws.com/0/43089/e231c820-3414-a48f-8bef-5def8cb58659.png">

[2016/08/04:追記]
veniceと別のgemを紹介していただきました。 @mono0926 さん、ありがとうございます！
https://github.com/mbaasy/itunes_receipt_validator

[2017/05/11:追記]
veniceの上記のissueは解決済みになっていました！
https://github.com/nomad/venice/issues/29


# サーバーサイドレシート検証の処理構成
## 月額課金購入時
<img width="750" alt="スクリーンショット 2016-08-03 17.50.02.png" src="https://qiita-image-store.s3.amazonaws.com/0/43089/67450164-946f-4473-9280-2eea55c0aa24.png">

## 更新時
- 今回の実装対象のサービスは、課金処理を始めた当初、クライアント側のみで認証を行っており、途中でサーバーサイドの検証に変更したため、古くから課金しているユーザーはサーバーサイドにBase64エンコード済みのレシートを保持していません。なので、一度クライアント側に問い合わせてレシートを受け取ってから検証を行うようにしました。
- アプリ立ち上げ時の課金状況の問い合わせを毎回行うと重くなるため、ユーザーテーブルに保持してある課金期限が過ぎているユーザーのみ行っています。

<img width="750" alt="スクリーンショット 2016-08-03 17.50.10.png" src="https://qiita-image-store.s3.amazonaws.com/0/43089/73fabb41-38e3-1cca-34ad-809caf806e77.png">


# AppStoreから返ってくるレシート情報の項目
APIが返す項目の公式ドキュメントは下記のリンクです。

<a href = 'https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1'>公式ドキュメント</a>

注意しなければならないのが、古くから課金コンテンツを営んでいるアプリだとその当時のバージョンのAPIが返ってきます。今回の実装対象のサービスの課金コンテンツは2014年に開始されているためか、iOS6タイプのAPIが返ってきています。

その証拠に、`only returned for iOS6` と書いてある下記の２つの項目がAPIのレスポンスに含まれています。

<img width="1189" alt="スクリーンショット 2016-07-31 17.40.48.png" src="https://qiita-image-store.s3.amazonaws.com/0/43089/281f3fc1-a961-c79f-4ebd-26c36c35e641.png">

途中でサーバーサイド検証に置き換えるような場合、最新ドキュメントの通りにAPIが返らない可能性があるため注意が必要です。ドキュメントだけを信頼せず、実際に返ってくるフィールドを見ながら実装をすすめることをおすすめします。(iOS7以降のAPIバージョンでもiOS6のみが返す項目と書いてある`latest_receipt`が返るという記事もあったので、公式ドキュメントの全てが正しい情報ではないかもしれません。)

※ [危険！！！！]iOS6タイプのトランザクション形式を使っている人への注意
`receipt`フィールドに含まれる`in_app`という項目は、公式ドキュメントの説明文には

```
In the JSON file, the value of this key is an array containing all in-app purchase receipts. 
```

と書いてありますが、実際には1ヶ月毎の更新が少し遅れることがあったので最新レシートの認証を行う場合は`latest_receipt_info`のほうを使うことをおすすめします。こちらのほうはリアルタイムで更新されます。

[2016/08/04:追記]
<a href='http://stackoverflow.com/questions/25755004/use-in-app-or-latest-receipt-info-for-getting-latest-receipt-for-auto-renewable'>Use in_app or latest_receipt_info for getting latest receipt for auto-renewable iOS 7 style transactions?</a>

この記事とまさに同じことが起こりました

```
The "in_app" JSON element contains LESS receipts than "latest_receipt_info". I was expecting both
elements to contain the same number or receipts.

Also, I was expecting that the "in_app" element would contain ALL the receipts. However, it appears
that "latest_receipt_info" actually contains all the receipts. Apple documentation seems to suggest
to use "in_app" for finding a latest receipt.
```

# 検証内容
## レシートの内容で検証すべき項目

|項目|項目の内容|検証内容|
|---|---|---|
|status|0であれば正常なレシート、その他は不正なレシート(<a href='https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW5'>エラーコード表</a>参照)|AppStoreから正常なレシートが返ってきているか|
|in_app又はlatest_receipt_info|過去の購入履歴|課金履歴が存在しているか|
|bundle_id|iTunesConnectで設定したCFBundleIdentifierの値|自分のアプリのものか|
|product_id|iTunesConnectで設定したproductIdentifierの値|意図した商品への課金か|
|transaction_id|1ヶ月ごとのレシート毎に発行される固有のid|別のユーザーのレシートを使っていないか
|original_transaction_id|ProductごとのAppStore固有のid|同じAppStoreで別のアカウントが既に課金していないか
|expires_date|レシートの期限|期限は切れていないか|


## 各テーブルに保持すべき項目
### ユーザーテーブルに持つべき項目

|項目|内容|用途|
|---|---|---|
|purchase_device|どのプラットフォームで課金しているユーザーなのか|クロスデバイス対応の際に有用|
|premium_expire_time|課金の期限|レシート検証対象のユーザーかどうかをチェック|

### レシートテーブルに持つべき項目

user_idとレシートの内容全部

# サーバーサイドのレシート検証で考慮すべきこと
## クロスデバイス
状況

- AndroidからiOSへの機種変更時(逆も然り)
- 複数デバイスでのログイン

対処方法

- ユーザーテーブルに保持したpurchase_deviceを確認し、どのプラットフォームで課金しているユーザーなのかをチェック
- AppStoreのレシート検証が通らなかったら、別のデバイスの検証で課金の有無をチェック

例)
<img width="725" alt="スクリーンショット 2016-08-02 17.29.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/43089/3ec2edd3-c787-36fa-b2b2-4a45b88234ce.png">

## ダブルアカウント

状況(ニッチな状況ですが)

- アカウントを2つ持っている
- 過去に片方のアカウントで課金していたことがある
- 現在つかっているAppStoreのアカウントが過去に課金していたAppStoreと同じアカウント
- original_transaction_idによる認証をサーバーサイドで行っている(original_transaction_idはProductごとにAppStoreで固有の値なので、前に課金していた時と再度課金する時のどちらも同じ値で返ります)
- もう片方のアカウントでもう一度課金したい

対処方法

- original_transaction_idが一緒な他のユーザーが居ても、片方のアカウントでユーザーテーブルのexpires_dateが過ぎていれば認証OKにする

例)
<img width="809" alt="スクリーンショット 2016-08-02 17.29.47.png" src="https://qiita-image-store.s3.amazonaws.com/0/43089/7f5f677b-cba7-248b-2d6f-7d5cf4f93298.png">


# まとめ
iOSのサーバーサイドレシート検証の実装のメリットから実際の検証方法、考慮する点をまとめました。
状況によって考慮すべき点や検証すべき項目、ハマるポイントが違うと思うので、公式ドキュメントはもちろんのこと、複数の実装事例の記事を読んでみると良いと思います。
最後まで読んでいただき、ありがとうございました！


# 参考資料

- 公式ドキュメント

<a href = 'https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1'>Validating Receipts With the App Store</a></br>
<a href='https://developer.apple.com/jp/documentation/ValidateAppStoreReceipt.pdf'>レシート検証プログラミングガイド
</a>


- ブログ

参考になったブログです。ありがとうございます！

<a href='http://ameblo.jp/principia-ca/entry-12071724382.html'>自動購読課金について【iOS編】</a></br>
<a href='http://inside.pixiv.net/entry/2014/12/09/111310#f-da2f8457'>iOS/Androidアプリ内課金の不正なレシートによる有料会員登録を防ぐ</a>