【StoreKit2 / Swift / iOSアプリ開発】StoreKit 2 で月額サブスクリプションを実装するぅ!!!サーバー通知もnginx+goで受信!アプリからもサーバー検証!

iOS

ちょっとサブスクリプションを確認したら、サーバーを通すチェックが必要とか
firebase使えるけどユーザーが増えると高額になるとか…vpsで自前で用意するのも止まると困るので。。。と考えていたら!?

StoreKit2 は、アプリだけで完結できるということを確認!よしやりましょう!

StoreKit2
https://developer.apple.com/jp/videos/play/wwdc2021/10114/
StoreKit2 + SwiftUI
https://developer.apple.com/videos/play/wwdc2023/10013/

早速やってみましょう!

App Store Connect の設定

まず、App Store Connect でサブスクリプションを登録します。

  1. App Store Connect にログイン
  2. 対象アプリを選択
  3. 収益化→サブスクリプションから
    サブスクリプショングループを作成
  4. グループにサブスクリプションを作成(配信先、金額など設定
    1ヶ月 $0.99など
  5. ローカライズ(名前や説明を入力)

という感じで保存しました。

請求の猶予期間
全てのユーザー、更新のユーザー、sandboxのみなど選択できるようです
3日、16日 , 28日 から選択できるようです、更新時にカード期限切れなどの場合猶予を持たせたりするのに良いようです
なしでも大丈夫です。

無料トライアルなどは、猶予ではなく普通に無料で回数制限で使ってもらうなどで良いかと

Sandboxテスター

通常ユーザーでテストするとこうなりました。

sandboxテスターに入れると 「5分ごとの月次更新」など確認できます!

実機テストなのですが
設定 > App Store > SANDBOXアカウント
これを変えるだけでOKでした!

購入後、購入履歴を削除して、サブスクリプションを解除してみる

appstoreのsandboxアカウントをチェックして「購入履歴を削除」します。すぐには消えない?

購入済みアイテムが購入できるアイテムになければOKです^^v

購入中断テスト?

appstoreconnectのsandboxアカウントを購入中断

これはうまく中断できていないかも?普通に購入できているような

ひとまず置いておいて

レシート検証と通知

レシート検証

transaction内のレシート検証を行うことが出来ます。
verifiedされたtransactionなので通常はOKなのですが、それをサーバー側でも検証すると不正検知できたりということのようです。

verifiedされているのに、サーバー経由でappstoreに確認したら不正だった。ということもあるかもしれません。
過去にverifiedしたtransactionデータを再利用したり。などあるそうで。

不正データだったとしても検知するだけではなく、それをどう処理するかのロジックをアプリ側で対応しないといけません、不正なので機能停止。など

storekit1ではレシート検証が必須だったようです、2では推奨のようです。

レシート検証は非同期で行うだけなので、即時購入停止などはできません。transactionはverifiedされているので一旦transactionはfinishして検証結果に合わせてアイテム付与などを行うということですね

ゲームなどはサーバー側でユーザーのアイテムデータを管理することも多いと思うのでその辺りはまた重要になってきそうです。

今そこまで重要度は高くないですが、とりあえずレシート検証も入れてみます。

通知

appstoreに登録するとサーバーに購入関連通知を受け取ることができます。
このユーザーはアイテム使ったのに課金がキャンセルされている!など?あるのかな

通知とレシート検証を検証してみたい。などなど

通知を受け取るのは簡単なので受け取って保存してみましょう

App Store Server Notifications V2

App Store Server Notifications V2 | Apple Developer Documentation
Specify your secure server’s URL in App Store Connect to receive version 2 notifications.

通知を受け取るには?

  1. App Store Connect での設定:
    • App Store Connect にログインし、アプリを選択します。
    • 「App の情報」>「App Store Server Notifications V2」で、通知を受け取るサーバーの URL を設定します。

プロダクションサーバーとsandboxサーバーのURLを入れます!

  1. サーバー側の実装:
    • Apple から送信される通知を受け取るための API エンドポイントをサーバーに実装します。
    • 受信した通知の JSON ペイロードを検証し、必要な情報を抽出します。
    • トランザクションの情報をデータベースなどに保存し、管理します。
  2. 通知の検証:
    • Appleから受け取った通知は、JWS署名されています。
    • Appleの公開鍵を使って署名を検証し、通知の信頼性を確認する必要があります。

今回はGoでエンドポイントを作ってみます!
ちょっとテーマが大きいのでGoで作る部分は別記事を投稿します

続く。

通知メモ

App Store Server Notifications V2 | Apple Developer Documentation
Specify your secure server’s URL in App Store Connect to receive version 2 notifications.

公開鍵が公開されてないので検証ができない?

証明書チェーン検証と署名検証

App Store Server Notification (v2)signedPayload を処理

  • signedPayload(JWS)を受け取る
  • ヘッダーの x5c から証明書チェーンを検証
  • リーフ証明書の公開鍵で JWS 署名を検証
  • ペイロード(JSON)を安全に取り出す

Apple PKI – AppleRootCA-G3.pem は以下からダウンロード:
📎 https://www.apple.com/certificateauthority/
→ 「Apple Root CA – G3」を選んで .cer.pem に変換(または直接PEM形式にして保存)

# DER (.cer) → PEM 変換
openssl x509 -inform der -in AppleRootCA-G3.cer -out AppleRootCA-G3.pem

$ openssl x509 -inform der -in AppleRootCA-G3.cer -out AppleRootCA-G3.pem
$ ls
AppleRootCA-G3.cer  AppleRootCA-G3.pem

github.com/golang-jwt/jwt/v5をインストール

$ go get github.com/golang-jwt/jwt/v5
go: added github.com/golang-jwt/jwt/v5 v5.2.2

nginx -> go で Appleからの通知取得に成功しました!

type=SUBSCRIBED, subtype=INITIAL_BUY サブスク初回購入ですね^^

2025/04/08 05:45:29 Received notification: type=SUBSCRIBED, subtype=INITIAL_BUY
[GIN] 2025/04/08 - 05:45:29 | 200 |   20.633415ms |    17.58.253.22 | POST   

DID_RENEW 更新通知も来ました

2025/04/08 05:50:05 Received notification: type=DID_RENEW, subtype=
[GIN] 2025/04/08 - 05:50:05 | 200 |     6.27891ms |     17.58.253.7 | POST     "/notifications_sdbx"

httpsや使用するport は 17.0.0.0/8 に制限して良いですね ( appleの
他のものも使う場合は、nginxで限定しても良いですね
user向けのAPIとは別が良いかと思いますが、

両方同じサーバーの場合は appleからの通知用のuriには以下を入れました。

allow 17.0.0.0/8;
deny all;

購入時のチェックも – レシート検証?

検証して不正だったらアプリの課金状態を解消する(apple側ではなく、アプリ側の対応のみ

みたいな。自分のアプリは、課金で何かパワーを持つことはないのですが、ゲームなどアイテム課金が重要な場合は、サーバーとの整合性等重要ですね。

今回は transactionが不正?だったらアプリ内にフラグを立てて、サーバー側にも記録すると。


app_account_tokenでユーザーを識別できるので
uuid / keychainで課金状況をチェックしてみました↓

【Swift / iOSアプリ開発】UUID / KeyChain でid管理します
課金状況の確認にユーザーを管理するものがないので UUIDとKeychainでidを管理してみます。storekit2で購入する際に app account token というのがあるのですがそこにも利用します。すると 通知にも ...

UUIDを app account tokenに反映させる

let result = try await StoreKit.Product.products(for: [product.id]).first?.purchase(options: [.appAccountToken(uuidManager.userUUID)])

transaction finishの後に下のデータを送信してデータベースに入れました。

let payload: [String: Any] = [
            "bundleId":bundleID,
            "bundleVersion":buildVersion,
            "transactionId": String(transaction.id),
            "originalTransactionId": String(transaction.originalID),
            "productId": transaction.productID,
            "storefront": storefront,
            "purchaseDate":   transaction.purchaseDate.timeIntervalSince1970,
            "expirationDate": transaction.expirationDate?.timeIntervalSince1970 ?? 0,
            "environment": environment,
            "appAccountToken": transaction.appAccountToken?.uuidString.lowercased() ?? ""
        ]
await transaction.finish()
Task {
  await sendTransactionToServer(transaction)
}

sendTransactionToServerは送るだけなのでTaskで非同期で awaitを外すだけでも良いようですが
Task内でエラー処理などする場合に良いようです

クライアント(ユーザー)からのデータとapple通知両方確認することができました◎

ユーザーからのデータは、SHA256でハッシュ値を作って改ざん無ければくらいをチェックしているだけです。

ipaddressも保存しておきます。

nginxからは

  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

---
appleからの通知は appleのipだけ許可しています
 location /api/ {
      allow 17.0.0.0/8;
      deny all;
  }
func getClientIP(c *gin.Context) string {
    // X-Real-IP ヘッダー
    if ip := c.GetHeader("X-Real-IP"); ip != "" {
        return ip
    }

    // X-Forwarded-For にフォールバック
    if ip := c.GetHeader("X-Forwarded-For"); ip != "" {
        ips := strings.Split(ip, ",")
        return strings.TrimSpace(ips[0]) // 最初のIPが元々のクライアントの可能性
    }
    ip, _, err := net.SplitHostPort(c.Request.RemoteAddr)
    if err != nil {
        return c.Request.RemoteAddr // fallback
    }

    return ip
}

こんな感じでipを取得して保存しました^^

これで何らかの異常があればチェックできます。

今回はサブスクリプションなので
アプリでも課金済みを定期的にチェックしているので通常問題ないです。
アイテム課金でサーバーとの整合性が必要な場合、ここから丁寧にチェック入れないとですね

お気軽にコメントください!

スパム対応のためコメント認証に数日かかることがありますが、お気軽にコメントいただけると嬉しいです^^

コメント

タイトルとURLをコピーしました