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

內購付費作為大部分免費 app 主要的收如來源, 商品收據驗證尤為重要, 可以幫助開發方防止惡性用戶或者不預期程式錯誤取得未購買的商品. 較好的做法是在取得購買收據後且分發商品之前進行收據驗證. 主要有兩種驗證方式: 本地 (Client-side), 遠程 (Server-side).

本地 (Client-side):
於購買的裝置上進行驗證, 不需要將收據資料送往其他端口. 這是 Unity IAP 有提供的功能. 雖然 Unity IAP 提供本地驗證, 但本地驗證仍是容易遭到竄改, 最好還是把核心驗證交由遠程的伺服器驗證.

遠程 (Server-side):
將購買的收據下載至裝置上, 隨後發送至驗證伺服器, 驗證成功即發送對應商品獎勵. 目前 Unity IAP 尚未提供. 常見的平台 Google PlayApple 有更詳細的文件提供開發者進行更安全的遠程驗證.

儘管理想情況是採用遠程驗證, 但也有無法實現的情況. 像是對於單機不連網的 app 也可以設計成透過內購付費解鎖進階內容, 因此 Unity IAP 提供的本地驗證是個可供選擇的工具.

本篇將針對本地驗證, 了解其運作機制以及如何實作.
版本資訊:
Unity: 2019.2.14f
Unity IAP: 2.2.7

密鑰混淆

目前 Unity IAP 僅整合了 Google Play 與 App Store 的本地驗證. 驗證方法使用到這兩種平台提供的密鑰, 分別是 Google Play Public Key 以及 Apple 根憑證. 為了不讓 app 用戶端輕易獲取/修改這些密鑰, Unity IAP 提供一種工具, 以混淆替換這些密鑰.

工具欄 Window > Unity IAP > Receipt Validation Obfuscator

按照該工具窗上的指示, 輸入 Google Play Public Key (如果只有 App Store 一個目標平台的話, 不用輸入), 按下 “Obfuscate … Key" 將會把 Apple 根憑證 (不需要特別設定) 以及 Google Play Public Key 分別轉換成兩個不同的 C# 檔案: AppleTangle.cs and GooglePlayTangle.cs.

AppleTangle.cs 與 GooglePlayTangle.cs 存放著經過混淆後的密鑰/憑證並轉成 base64 encrypted bytes. 以下進行本地驗證將會使到到這兩個檔案. (注意不管只有一個目標平台還是兩個平台都有, 都要產生兩個檔案!!!)

收據驗證

來到程式碼的部分, 使用 CrossPlatformValidator 類別驗證 Google Play 與 App Store 商店收據. 在 Unity IAP 初始化前建立 CrossPlatformValidator 實體 (避免每次購買都產生).

public void Initialize()
{
	//...
	
	string appIdentifier;
	#if UNITY_5_6_OR_NEWER
	appIdentifier = Application.identifier;
	#else
	appIdentifier = Application.bundleIdentifier;
	#endif
	validator = new CrossPlatformValidator(GooglePlayTangle.Data(), AppleTangle.Data(), appIdentifier);
	
	// Now we're ready to initialize Unity IAP.
	UnityPurchasing.Initialize(this, builder);
}

CrossPlatformValidator 建構式傳入兩個平台的憑證密鑰及 app 包名. (更多內容請參考)

Unity IAP 本地驗證會進行兩項檢查:
1. 雙平台的收據的真實性 (以 signature 簽名驗證)
2. app 包名與收據中的包名匹配驗證. (不匹配則會發生 InvalidBundleId 例外狀況)

需要注意 CrossPlatformValidator 只能驗證 Google Play 或 App Store 雙平台的收據, 若是用於其他平台 (包含在 Editor 產生的 fake 收據) 會發生 IAPSecurityException 例外.

public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
{
	// ...
	m_PurchaseInProgress = false;
	
	if (m_IsGooglePlayStoreSelected ||
		Application.platform == RuntimePlatform.IPhonePlayer ||
		Application.platform == RuntimePlatform.OSXPlayer ||
		Application.platform == RuntimePlatform.tvOS) 
	{
		try 
		{
			var result = validator.Validate(e.purchasedProduct.receipt);
			Debug.Log("Receipt is valid. Contents:");
			foreach (IPurchaseReceipt productReceipt in result) 
			{
				Debug.Log(productReceipt.productID);
				Debug.Log(productReceipt.purchaseDate);
				Debug.Log(productReceipt.transactionID);

				GooglePlayReceipt google = productReceipt as GooglePlayReceipt;
				if (null != google) 
				{
					// This is Google's Order ID.
					// Note that it is null when testing in the sandbox
					// because Google's sandbox does not provide Order IDs.
					Debug.Log(google.transactionID);
					Debug.Log(google.purchaseState);
					Debug.Log(google.purchaseToken);
				}

				AppleInAppPurchaseReceipt apple = productReceipt as AppleInAppPurchaseReceipt;
				if (null != apple) 
				{
					Debug.Log(apple.originalTransactionIdentifier);
					Debug.Log(apple.subscriptionExpirationDate);
					Debug.Log(apple.cancellationDate);
					Debug.Log(apple.quantity);
				}

				// For improved security, consider comparing the signed
				// IPurchaseReceipt.productId, IPurchaseReceipt.transactionID, and other data
				// embedded in the signed receipt objects to the data which the game is using
				// to make this purchase.
			}
		} 
		catch (IAPSecurityException ex) 
		{
			Debug.Log("Invalid receipt, not unlocking content. " + ex);
			return PurchaseProcessingResult.Complete;
		}
	}
	
	// ...
	return PurchaseProcessingResult.Complete;
}

若僅僅驗證收據的真實性是不太安全的, 因為用戶端可能遭到竄改使用重複的收據或者是其他 app 用過的收據, 這些收據是真實的且能通過驗證, 所以開發者還需要自行檢查經過 CrossPlatformValidator 驗證過的收據內容 (IPurchaseReceipt 類型).

IPurchaseReceipt 類型的收據可以向下轉型成兩種不同的子類型: GooglePlayReceipt, AppleInAppPurchaseReceipt
這些類別整理了收據中的內容, 方便開發者直接獲取.

其他問題

1. Google Play Public Key 取得方式參考.

2. 如果沒有提供目標平台所需的密鑰而直接呼叫 CrossPlatformValidator.Validate 會遇到 MissingStoreSecretException 例外狀況.

3. GooglePlayReceipt, AppleInAppPurchaseReceipt 畢竟是經過 Unity IAP 整理過的內容, 只保留了收據表面的基本資料, 如果後續還需要執行遠端驗證建議還是從原始收據字串獲取資料.

參考

Unity IAP Receipt Validation – https://docs.unity3d.com/2019.4/Documentation/Manual/UnityIAPValidatingReceipts.html
Unity IAP Receipt – https://docs.unity3d.com/2019.4/Documentation/Manual/UnityIAPPurchaseReceipts.html

在〈Unity 嵌入 app 內購付費 (In-App Purchasing) – Client-Side 驗證篇〉中有 1 則留言

發表留言