前篇提到 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 形式的收據:
| Name | Description |
| Store | 描述來自哪個商店, GooglePlay 或 AppStore. |
| TransactionID | 由商店提供的交易訂單ID (唯一碼) |
| Payload | 收據詳細內容, 因各商店而異. (遠程驗證所需資料存放這) |
以下 Google Play 收據範例:
{
"Store":"GooglePlay",
"TransactionID":"GPA.xxxx-xxxx-xxxx-xxxxx",
"Payload":"..."
}
Google Play 的 Payload 存放 JSON 形式:
| Name | Description |
| 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).
因此主要流程就變成…
- 發送 json, signature 至伺服器端
- 轉換 based64 encrypted public key 成 cryptographic 形式 – using RSACryptoServiceProvider (C#)
- 轉換 json 成 UTF8 bytes
- 轉換 signature 成 UTF8 bytes
- 使用 RSACryptoServiceProvider.VerifyData 驗證 (選用SHA1方法)
- 返回的 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 端.
| URL | HTTP POST https://buy.itunes.apple.com/verifyReceipt https://sandbox.itunes.apple.com/verifyReceipt |
| HTTP Body | requestBody |
| Response Codes | responseBody |
App Store 的收據根據不同的產生方式, 分送到不同的驗證端口.
因此, 官方建議都先發送至正式端口, 若收到 responseBody 的 21007 狀態碼代表該收據是 Sandbox 產生的, 再轉送到 Sandbox 端口.
| Build | Receipt | URL |
| Debug & Ad-Hoc builds | Sandbox receipts | https://sandbox.itunes.apple.com/verifyReceipt |
| TestFlight or App Store builds | Production receipts | https://buy.itunes.apple.com/verifyReceipt |
requestBody 是 JSON 形式的內容: 包含 base64 encrypted 字串以及 password.
| receipt-data | Base64 編碼的收據資料 |
| password | app 的 shared secret, 16進位字串 |
password 欄位只有在自動續訂閱需要帶入, 其他種購買不需要帶入, 關於如何獲取 password (16進位的 shared secret) 請參考.
如果請求成功, 將收到 HTTP 200 OK 的回應. 這代表可以接著解析 JSON 形式的 responseBody:
| environment | 描述收據是如何產生的, Sandbox 或 Production. |
| … (暫時忽略) | … |
| receipt | JSON 形式的收據內容 |
| status | 0 表示驗證成功. 其他狀態碼參考 |
| … (暫時忽略) | … |
同樣, 以下用 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