Unity 嵌入 app 內購付費 (In-App Purchasing) – Server-Side 驗證篇

前篇提到 Unity IAP 收據的兩種驗證方式: 本地(Client-side), 遠程(Server-side); 也介紹以 Unity IAP 提供的工具與 CrossPlatformValidator 類別執行本地驗證, 但對於提供遠程服務的 app 有更好的選擇: 遠程驗證.

本篇將介紹且實作 Google Play 與 App Store 兩平台的遠程收據驗證.
版本資訊:
Unity: 2019.2.14f
Unity IAP: 2.2.7

Unity IAP 的收據

Unity IAP 提供了 JSON 形式的收據:

NameDescription
Store描述來自哪個商店, GooglePlay 或 AppStore.
TransactionID由商店提供的交易訂單ID (唯一碼)
Payload收據詳細內容, 因各商店而異. (遠程驗證所需資料存放這)

以下 Google Play 收據範例:

{
	"Store":"GooglePlay",
	"TransactionID":"GPA.xxxx-xxxx-xxxx-xxxxx",
	"Payload":"..."
}

Google Play 的 Payload 存放 JSON 形式:

NameDescription
json由 Google 提供的 JSON 形式字串, 包含 orderId, packageName, …等訂單資料.
signature由 Google 提供的 json 參數簽名, 用於驗證 json 內容真實性.
… 其他… 其他

iOS (Mac) App Store 的 Payload 則是存放 base64 編碼的收據內容.

驗證 Google Play 收據

Google 鼓勵開發者使用收據中的 signature 驗證購買行為. 該方式能在用戶端執行, 但如果 app 有溝通的遠程伺服器, 最好還是在服務器上驗證. JSON 形式的 Payload 包含 json, signature, skuDetails, …

json 也是 JSON 形式:

{
	"orderId":"GPA.xxxx-xxxx-xxxx-xxxxx",
	"packageName":"com.xxx.xxx.xxx",
	"productId":"com.xxx.xxx.gem01",
	"purchaseTime":1623573497104,
	"purchaseState":0,
	"purchaseToken":"....",
	"acknowledged":false
}

signature 則是 base64 encrypted 字串形式:

JpW65RekBFmUtKs7361vBBlwcRv92sk/f3y9pE5B0BMWUBPTTibq/bE6bowA4d26Un9pPq5kAOXJkyhRYfbMB6GmvblNNdO/FcXWQmme2fmlFXTh/3XtLPfA9GmJ4GefcMQENMlLfhItENTnZK7groKs36Ejld1tyrW51f+VamrdK+ENuM35iuLyMyrzMoJ0Dha5mLG6eUyrccKfJKrB9T0mo030u1bXe56m6dVRHxZvyhCZclhYMo/gLzLh/5uI+Q3NpOWPp8s+KocYL8o3lbgjNAgCkqJ7ntruQt1Q3XVy7E8qwXMJWFCHVtT2ogwHHi91rMHLfCJxwtNIFy2Jug==

signature 是將 json 參數內容以 SHA1 加密產生的, 使用的私鑰是 Google 產生的且開發者無法獲得, 但 Google 提供公鑰供開發者驗證簽名收據 (也就是上篇上上篇提到的 base64 encrypted 的 Google Play public key).

因此主要流程就變成…

  1. 發送 json, signature 至伺服器端
  2. 轉換 based64 encrypted public key 成 cryptographic 形式 – using RSACryptoServiceProvider (C#)
  3. 轉換 json 成 UTF8 bytes
  4. 轉換 signature 成 UTF8 bytes
  5. 使用 RSACryptoServiceProvider.VerifyData 驗證 (選用SHA1方法)
  6. 返回的 true/false 即為驗證結果

以下用 C# .NET Core 3.1 WebAPI 實現簡單的 Web 服務

using System;
using System.Text;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using PublicKeyConvert;

namespace testapi.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class GooglePurchasingVerifyController : ControllerBase
    {
		// enter your google public key
        private readonly string _public_key = "";

        private readonly ILogger _logger;

        public GooglePurchasingVerifyController(ILogger logger)
        {
            _logger = logger;
        }

        // json_data, signature
        [HttpGet]
        public string Verify(string json_data, string signature)
        {
            bool result = false;
            try
            {                
                RSACryptoServiceProvider preProvider = PEMKeyLoader.CryptoServiceProviderFromPublicKeyInfo(_public_key);
                string publicKeyXml = preProvider.ToXmlString(false);

                // Create the provider and load the KEY
                RSACryptoServiceProvider provider = new RSACryptoServiceProvider();
                provider.FromXmlString(publicKeyXml);

                // The signature is supposed to be encoded in base64 and the SHA1 checksum
                // Of the message is computed against the UTF-8 representation of the 
                // message
                byte[] signatureBytes = Convert.FromBase64String(signature);
                SHA1Managed sha = new SHA1Managed();
                byte[] data = Encoding.UTF8.GetBytes(json_data);

                result = provider.VerifyData(data, sha, signatureBytes);
            }
            catch (Exception ex)
            {
                _logger.LogError("Verify exception: " + ex.Message);
            }
            return "result: " + result;
        }
    }
}

驗證 App Store 收據

App Store 的收據是 base64 編碼字串 (以 Apple 證書簽名產生). 因此, 為了解讀收據內容, 必須將該字串發送至 verifyReceipt 端.

URLHTTP POST
https://buy.itunes.apple.com/verifyReceipt
https://sandbox.itunes.apple.com/verifyReceipt
HTTP BodyrequestBody
Response CodesresponseBody

App Store 的收據根據不同的產生方式, 分送到不同的驗證端口.
因此, 官方建議都先發送至正式端口, 若收到 responseBody 的 21007 狀態碼代表該收據是 Sandbox 產生的, 再轉送到 Sandbox 端口.

BuildReceiptURL
Debug & Ad-Hoc buildsSandbox receiptshttps://sandbox.itunes.apple.com/verifyReceipt
TestFlight or App Store buildsProduction receiptshttps://buy.itunes.apple.com/verifyReceipt

requestBody 是 JSON 形式的內容: 包含 base64 encrypted 字串以及 password.

receipt-dataBase64 編碼的收據資料
passwordapp 的 shared secret, 16進位字串

password 欄位只有在自動續訂閱需要帶入, 其他種購買不需要帶入, 關於如何獲取 password (16進位的 shared secret) 請參考.

如果請求成功, 將收到 HTTP 200 OK 的回應. 這代表可以接著解析 JSON 形式的 responseBody:

environment描述收據是如何產生的, Sandbox 或 Production.
… (暫時忽略)
receiptJSON 形式的收據內容
status0 表示驗證成功. 其他狀態碼參考
… (暫時忽略)

同樣, 以下用 C# .NET Core 3.1 WebAPI 實現簡單的 Web 服務 (以讀檔代替發送給 server 的參數)

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace testapi.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class ApplePurchasingVerifyController : ControllerBase
    {
	// enter your shared secret
        private readonly string _shared_secret = "";

        private readonly string _appStore_url = "https://buy.itunes.apple.com/verifyReceipt";
        private readonly string _appStore_sandbox_url = "https://sandbox.itunes.apple.com/verifyReceipt";

        private readonly ILogger _logger;

        public ApplePurchasingVerifyController(ILogger logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public string Verify()
        {
            string base64Code = "";
            bool result = false;
            string resultStr = "";
            try
            {   
                // read file                
                FileStream fileStream = new FileStream("base64_code.txt", FileMode.Open);
                using (StreamReader reader = new StreamReader(fileStream))
                {
                    base64Code = reader.ReadToEnd();
                }

                Dictionary respondJson = AppStoreValidate(base64Code, false);
                if (respondJson != null && 
                    respondJson.TryGetValue("status", out object status))
                {
                    if (int.TryParse(status.ToString(), out int statusNum))
                    {
                        // sandbox receipt
                        if (statusNum == 21007)
                        {
                            respondJson = AppStoreValidate(base64Code, true);
                        }
                    }

                    if (respondJson != null && 
                        respondJson.TryGetValue("status", out status))
                    {
                        resultStr = JsonSerializer.Serialize(respondJson);
                        if (int.TryParse(status.ToString(), out statusNum))
                        {
                            if (statusNum == 0)
                            {
                                result = true;
                            }
                            // other status
                            //https://developer.apple.com/documentation/appstorereceipts/status
                        }
                    }
                }
                
            }
            catch (Exception ex)
            {
                _logger.LogError("Verify exception: " + ex.Message);
            }
            return "AppStore: " + resultStr + "\nresult: " + result;
        }

        private Dictionary AppStoreValidate(string base64Code, bool isSandbox)
        {
            string url = isSandbox ? _appStore_sandbox_url : _appStore_url;
            try
            {
                WebRequest request = WebRequest.Create(url);
                request.Credentials = CredentialCache.DefaultCredentials;
                request.Method = "POST";
                request.ContentType = "application/json";

                // post params
                Dictionary key_values = new Dictionary();
                key_values.Add("receipt-data", base64Code);
                key_values.Add("password", _shared_secret);

                // send the request
                byte[] byteArray = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(key_values)); 
                using (Stream reqStream = request.GetRequestStream())
                {
                    reqStream.Write(byteArray, 0, byteArray.Length);
                }

                // get the response
                string responseStr = "";
                using (WebResponse response = request.GetResponse())
                { 
                    using (StreamReader sr = new StreamReader(response.GetResponseStream(),Encoding.UTF8))
                    {
                        responseStr = sr.ReadToEnd();
                    }
                }
                _logger.LogInformation(responseStr);

                Dictionary respondJson = JsonSerializer.Deserialize<Dictionary>(responseStr);
                return respondJson;
            }
            catch (Exception ex)
            {
                _logger.LogError("AppStore validate exception: " + ex.Message);
            }

            return null;
        }
    }
}

參考

Unity IAP Receipt – https://docs.unity3d.com/2019.4/Documentation/Manual/UnityIAPPurchaseReceipts.html
Checking google play signatures on net – http://mrtn.me/blog/2012/11/15/checking-google-play-signatures-on-net/
Validating Receipts with the App Store – https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/validating_receipts_with_the_app_store
App store receipt verification tutorial – https://www.namiml.com/blog/app-store-receipt-verification-tutorial

發表留言