之前写过一篇关于《iOS通过StoreKit2应用收据验证交易》的问题,最近有游客留言称“StoreKit2推荐的校验做法是仅用transactionId进行校验而无需凭据,跟文本讲解的使用凭据校验有些出入。”
因此我决定写一篇关于使用 Transaction数据进行安全的后端验证文章。在此之前还需要阐述一下,本文中的代码仍然以《iOS通过StoreKit2实现应用内购》文章的代码为基础,相关代码不理解的话,可以去前文中找寻。
前文概述
在Apple Developer的StoreKit 2 文章中,可以了解到StoreKit 2中,App Store以JSON Web Signature格式对交易进行加密签名。
这里提到的JSON Web Signature (JWS),就是StoreKit2引入的新机制,交易信息是以 JSON Web Signature (JWS) 格式编码的字符串。
什么是jsonRepresentation?
transaction.jsonRepresentation 是 StoreKit 提供的便捷工具,用于获取交易的明文 JSON 表示。在iOS通过StoreKit2中,也是通过jsonRepresentation方法获取到明文JSON并传递给后端验证交易。
内容:jsonRepresentation包含交易的完整信息,包括产品 ID、购买时间、订阅状态、有效期等,所有内容都被签名以保证完整性。
安全性:签名使用苹果的私钥,后端可以通过苹果的公钥验证签名的真实性,从而确保数据未被篡改。
用法:
1、客户端将 jsonRepresentation发送到后端。
2、后端通过验证 JWS 签名和解析其中的信息来确认交易。
优点:
避免了传统收据中复杂的解码和校验流程。
更高效,尤其适合需要实时校验的场景。
更加现代化,基于标准的 JWS 格式,易于与其他系统集成。
特点:
直接明文数据:jsonRepresentation 返回的内容未加密,因此无法通过公钥验证其真实性。
调试友好:适合在开发中查看交易数据内容或记录日志。
不适用于后端验证:因为这只是明文 JSON,无法保证其未被篡改。
使用方式
在 iOS 应用中通过 Transaction.jsonRepresentation实现后端验证交易,可以按以下步骤操作。
1、客户端获取 jsonRepresentation
jsonRepresentation是 Transaction 对象中的一个属性,包含了当前交易的完整信息。以下是获取 jsonRepresentation的流程:
代码示例
import StoreKit
// 发送到服务器端
func sendToServer(signedTransactionInfo: String?) {
guard let info = signedTransactionInfo else { return }
let url = URL(string: "https://your-backend-server.com/verify")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["signedTransactionInfo": info], options: [])
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
print("Error sending transaction info to server: \(error)")
return
}
print("Server response: \(String(data: data!, encoding: .utf8) ?? "")")
}
task.resume()
}
// purchaseProduct:购买商品的方法,返回购买结果
func purchaseProduct(_ product: Product) {
// 在这里输出要购买的商品id
print("Purchasing product: \(product.id)")
Task { @MainActor in
do {
let result = try await product.purchase()
switch result {
case .success(let verification): // 购买成功的情况,返回verification包含交易的验证信息
let transaction = try checkVerified(verification) // 验证交易
// 获取 JSON 表示的交易信息
let jsonData = transaction.jsonRepresentation
if let signedTransactionInfo = String(data: jsonData, encoding: .utf8) {
print("Signed Transaction Info: \(signedTransactionInfo)")
// 将交易信息发送到后端进行验证
sendToServer(signedTransactionInfo: signedTransactionInfo)
} else {
print("Failed to convert JSON data to string")
}
savePurchasedState(for: product.id) // 更新UserDefaults中的购买状态
await transaction.finish() // 告诉系统交易完成
print("交易成功:\(result)")
case .userCancelled: // 用户取消交易
print("用户取消交易:\(result)")
case .pending: // 购买交易被挂起
print("购买交易被挂起:\(result)")
default: // 其他情况
throw StoreError.failedVerification // 购买失败
}
} catch {
print("购买失败:\(error)")
await resetProduct() // 购买失败后重置 product 以便允许再次尝试购买
}
DispatchQueue.main.async {
self.loadPurchased = false // 隐藏内购时的加载画布
}
print("loadPurchased:\(loadPurchased)")
}
}
这里主要需要理解的是交易信息:
let jsonData = transaction.jsonRepresentation
这部分代码的作用是将交易信息转换为 JSON 数据:
transaction.jsonRepresentation:这是 StoreKit2 提供的属性,用于获取当前交易的 JSON 表示形式。
它返回一个 Data 对象,包含交易的完整信息(如 transactionId、productId、purchaseDate 等)。
为了便于观察交易信息,我在Xcode代码中将这个jsonData二进制数据(Data),按照UTF-8编码为String类型。
如果转换成功,代码会打印出交易的详细信息:
print("Signed Transaction Info: \(signedTransactionInfo)")
在沙盒环境中,输出的内容为:
Signed Transaction Info: {"transactionId":"2000000827612386","originalTransactionId":"2000000827612386","bundleId":"com.fangjunyu.piglet","productId":"20240523","purchaseDate":1736645519000,"originalPurchaseDate":1736645519000,"quantity":1,"type":"Non-Consumable","deviceVerification":"mHsPU3ifZDwPhXfriFMXu4RLupo7ZI8E0w0/BrtviNRT6HJck3I3ti8wAx77hwQs","deviceVerificationNonce":"22cd1eed-fc6f-4d3e-bc8f-3d35f32ad620","inAppOwnershipType":"PURCHASED","signedDate":1736827179227,"environment":"Sandbox","transactionReason":"PURCHASE","storefront":"CHN","storefrontId":"143465","price":8000,"currency":"CNY"}
而校验过程也是将这段JSON字符串传递到后端。
2、苹果提供的签名交易信息
返回的信息是苹果提供的签名交易信息 (Signed Transaction Info),其内容是 JSON 格式,包含了交易的详细数据。这些数据本身并不是 JSON Web Signature (JWS) 格式,而是一个标准的 JSON 字符串,包含的字段代表了交易的属性。以下是详细说明:
因为我的测试内购商品为一次性内购,购买后永久可用。针对续费类型的内购商品,可能含有更多的字段:
3、后端验证交易
通过 App Store Server API 验证
Apple 的 App Store Server API 是推荐的验证方式,支持更复杂的交易场景(如订阅、促销等)。可以向 Apple 发送 transactionId 或其他数据,并接收验证结果。
1)URL:
https://api.storekit.itunes.apple.com/inApps/v2/history/{transactionId}
2)沙盒URL:
https://api.storekit-sandbox.itunes.apple.com/inApps/v2/history/{transactionId}
根据Get Transaction History要求,需要提供transactionId。
通过PostMan调用,返回内容为:
Unauthenticated
Request ID: W4OGFMSTU56RSLWSLURX6QCVXE.0.0
经过查询了解到,Unauthenticated 错误表明请求未通过身份验证,这通常是因为请求缺少有效的开发者 JWT(JSON Web Token)。在调用 Apple 的 App Store Server API 时,必须在请求头中包含授权令牌。
因此,需要确保在请求头中包含Authorization。
Apple 的 App Store Server API 要求通过 Bearer Token 的方式进行身份验证。请求头示例如下:
Authorization: Bearer <developer_token>
如果 Authorization 头缺失或令牌无效,就会出现 Unauthenticated 错误。
生成有效的开发者 JWT
Apple 要求使用开发者账户的私钥和密钥信息生成 JWT。以下是生成步骤:
(1) 获取开发者信息
开发者 ID:登录 Apple Developer 后,在 Membership 页面中查看。
Key ID:在 Apple Developer 的 Keys 页面中创建一个新的 Key,并下载私钥(.p8 文件)。
创建App内购密钥后,密钥ID就是Key ID。
Private Key:下载的 .p8 文件内容。
需要注意的是.p8文件只有一次下载的机会,下载完成后,就不再显示新的下载链接。
Bundle ID:Xcode项目包id,可以在Xcode中或者前面返回的交易信息中查看:
"bundleId":"com.fangjunyu.piglet"
生成JWT
1、使用jwt-cli工具快速生成令牌
运行以下命令安装 jwt-cli(需要 Node.js 环境):
sudo npm install -g jwt-cli
生成令牌
1)创建一个js文件:
touch jwt_gen.js
2)按照依赖:
npm install jsonwebtoken
3)编辑js文件:
const fs = require('fs');
const jwt = require('jsonwebtoken');
// 读取私钥文件
const privateKey = fs.readFileSync('*.p8');
// 准备 header
const header = {
kid: "Key_ID", // Key ID
typ: "JWT", // 固定值
alg: "ES256" // 固定值
};
// 准备 payload
const payload = {
iss: "DEVELOPER_ID", // 开发者ID
iat: Math.floor(Date.now() / 1000), // 签发时间戳
exp: Math.floor(Date.now() / 1000) + 3600, // 结束时间戳
bid: "com.Bundle.ID", // 包ID
aud: "appstoreconnect-v1" // 固定值
};
// 生成 JWT
const token = jwt.sign(payload, privateKey, {
algorithm: 'ES256',
header: header
});
console.log(token);
4)执行脚本:
node jwt_gen.js
2、使用在线工具
可以通过在线工具 JWT.io 生成开发者令牌:
步骤
1)打开 JWT.io。
2)在左侧的 Header 部分,填写以下内容:
{
"alg": "ES256", // 固定值
"kid": "Key ID", // Key ID
"typ": "JWT" // 固定值
}
3)在左侧的 Payload 部分,填写以下内容:
{
"iss": "DEVELOPER ID", // 开发者ID
"iat": 1737207221, // 签发时间戳
"exp": 1737208221, // 过期时间戳,
"aud": "appstoreconnect-v1",
"bid": "com.BUNDLE.ID"
}
4)在右侧的 Verify Signature 部分:
点击 Private Key。
粘贴 .p8 文件内容(包括 —–BEGIN PRIVATE KEY—– 和 —–END PRIVATE KEY—–)。
因为涉及公钥,因此可以通过OpenSSL提取公钥:
运行以下命令从 .p8 文件中生成公钥:
openssl ec -in **.p8 -pubout -out public.pem
-in SubscriptionKey_N8FW3GCRN9.p8:输入你的私钥文件。
-pubout:生成公钥。
-out public.pem:将公钥输出到 public.pem 文件中。
需要注意的是,生成的文件路径如果没有指定,可能会生成在用户目录下。
生成的 public.pem 文件,在终端中使用cat命令查看:
cat /Users/***/public.pem
5)工具会自动生成一个有效的 JWT。
3、使用 Swift 生成开发者令牌
如果使用的是 macOS 或 iOS 开发环境,可以通过 Swift 生成 JWT。
步骤
1、创建一个新的 Swift 文件:
打开 Xcode,选择 “File” > “New” > “Project”。
选择模板为 “macOS” > “Command Line Tool”。
点击 “Next”,输入项目名称(例如 JWTGenerator),并选择 “Swift” 作为语言。
点击 “Create”。
2、将代码粘贴到 main.swift 文件:
在新创建的项目中,打开 main.swift 文件。
将如下代码复制到main.swift文件中:
import Foundation
import CryptoKit
extension Data {
/// 将 Base64 编码转换为 Base64URL 编码
func base64URLEncodedString() -> String {
return self.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
func generateDeveloperToken() -> String? {
let issuerID = "YOUR_DEVELOPER_ID" // 替换为你的 开发者 ID
let keyID = "YOUR_KEY_ID" // 替换为你的 Key ID
let privateKeyPath = "/path/to/AuthKey.p8" // 替换为你的私钥路径
// 读取私钥
guard let privateKeyData = try? Data(contentsOf: URL(fileURLWithPath: privateKeyPath)),
let privateKeyString = String(data: privateKeyData, encoding: .utf8) else {
print("无法读取私钥文件")
return nil
}
// 创建 JWT 头部
let header = [
"alg": "ES256", // 固定值
"kid": keyID, // Key ID
"typ": "JWT" // 固定值
]
// 创建 JWT 载荷
let payload = [
"iss": issuerID, // 开发者ID
"iat": Int(Date().timeIntervalSince1970),
"exp": Int(Date().timeIntervalSince1970) + 3600, // 一小时有效期
"aud": "appstoreconnect-v1", // 固定值
"bid": "com.BundleID" // 替换为你的 App 的 Bundle ID
] as [String : Any]
guard let headerData = try? JSONSerialization.data(withJSONObject: header) ,
let payloadData = try? JSONSerialization.data(withJSONObject: payload) else {
print("无法序列化 Payload")
return nil
}
// Base64URL 编码头部和载荷
let headerBase64URL = headerData.base64URLEncodedString()
let payloadBase64URL = payloadData.base64URLEncodedString()
// 拼接待签名数据
let toSign = "\(headerBase64URL).\(payloadBase64URL)"
// 使用私钥签名
guard let privateKey = try? P256.Signing.PrivateKey(pemRepresentation: privateKeyString) else {
print("私钥解析失败")
return nil
}
guard let signature = try? privateKey.signature(for: Data(toSign.utf8)) else {
print("签名生成失败")
return nil
}
// 编码签名并生成完整 JWT
let signatureBase64URL = signature.rawRepresentation.base64URLEncodedString()
return "\(toSign).\(signatureBase64URL)"
}
// 调用生成开发者令牌
if let token = generateDeveloperToken() {
print("Developer Token: \(token)")
} else {
print("生成 Developer Token 失败")
}
3、修改路径和参数:
替换 YOUR_TEAM_ID 和 YOUR_KEY_ID 为实际值。
将 “/path/to/AuthKey.p8” 替换为 .p8 文件的完整路径。
4、运行代码:
点击顶部工具栏的 “Run” 按钮。
查看终端窗口的输出,生成的开发者令牌会显示在控制台中。
4、使用其他语言
Java: 使用 Nimbus JOSE + JWT 库。
JavaScript: 使用 jsonwebtoken 或其他类似库。
Ruby/PHP: 类似 JWT 库都支持 ES256 签名。
Python:使用支持JWT的库(如Python 的 jwcrypto)。
5、检查参数
如果存在JWT Token无效或已过期的情况,需要如下情况:
1)检查过期时间是否比签发时间晚,并且在合理的区间内(Apple 通常要求有效期不超过 20 分钟),签发有效时间最多1小时,这也意味着过期时间最多为签发时间 + 3600秒。
2)算法设置为 ES256。
3)开发者ID 和 KeyID 必须正确。
4)有效的 aud 字段,应该是 appstoreconnect-v1。
5)使用正确的 .p8 文件
调用App Store Server API验证
1、使用curl调用
curl -v -H 'Authorization: Bearer eyJh...开发者JWT...' \
https://api.storekit-sandbox.itunes.apple.com/inApps/v2/history/200...transactionId...
如果返回报错,请检查Get Transaction History文章中的报错内容。
2、使用POSTMAN调用
调用地址:
https://api.storekit-sandbox.itunes.apple.com/inApps/v2/history/200...
// 200...表示交易信息中的transactionId
在POSTMAN中选择GET方法,在Headers中添加
Authorization Bearer eyJ...// eyJ...表示开发者JWT
最后,通过调取返回如下字段:
{
"revision": "173..._5",
"bundleId": "com.fangjunyu.piglet",
"environment": "Sandbox",
"hasMore": false,
"signedTransactions": [
"eyJhbGc..."
]
}
需要注意的是,signedTransactions 是一个 JWT (JSON Web Token),可以分为三个部分:Header、Payload 和 Signature。
signedTransactions 是一个 Base64 编码的 JWT,验证步骤如下:
解码 JWT
JWT 由三部分组成,用 . 分隔:
Header:JWT 的元数据(例如签名算法、类型等)。
Payload:交易的实际数据。
Signature:签名,用于验证数据完整性和真实性。
解码方式
1、在线工具:jwt.io
2、使用 Swift 验证:
//
// main.swift
// JWTGenerator
//
// Created by 方君宇 on 2025/1/18.
//
import Foundation
func decodeJWT(_ jwt: String) -> (header: [String: Any]?, payload: [String: Any]?) {
let segments = jwt.components(separatedBy: ".")
guard segments.count == 3 else {
print("Invalid JWT format")
return (nil, nil)
}
func decodeBase64URL(_ string: String) -> Data? {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while base64.count % 4 != 0 {
base64.append("=")
}
return Data(base64Encoded: base64)
}
let headerData = decodeBase64URL(segments[0])
let payloadData = decodeBase64URL(segments[1])
let header = headerData.flatMap {
try? JSONSerialization.jsonObject(with: $0, options: []) as? [String: Any]
}
let payload = payloadData.flatMap {
try? JSONSerialization.jsonObject(with: $0, options: []) as? [String: Any]
}
return (header, payload)
}
// 顶层代码直接运行
let jwt = "eyJhbGc..." // 替换为你的 JWT
let decoded = decodeJWT(jwt)
print("Header: \(decoded.header ?? [:])")
print("Payload: \(decoded.payload ?? [:])")
需要注意的是,这里的Swift代码,也是在前面创建Xcode项目中的main文件中运行的。
后端验证完成流程
1、解析 JWT 并提取信息
通过解码 JWT,从 Payload 中提取购买信息(如 transactionId、productId、bundleId 等)。
2、校验关键信息
确保以下关键信息与应用的预期相符:
bundleId:确认是你应用的唯一标识。
productId:确认用户购买了正确的商品。
transactionId 和 originalTransactionId:检查是否重复(防止双重消费)。
currency 和 price:验证金额是否正确。
environment:区分是否为生产环境或沙盒环境(Sandbox 应为测试数据)。
3、验证签名合法性(可选)
如果需要确保数据未经篡改,可以使用 Apple 提供的公钥验证 JWT 的签名。
4、记录订单状态
将交易记录保存到后端数据库中,记录以下关键信息:
用户 ID
商品 ID(productId)
交易 ID(transactionId)
交易时间(purchaseDate)
交易金额(price 和 currency)
5、处理逻辑(比如解锁内容)
根据 productId 和用户的购买状态,提供相应的解锁或服务。
6、返回结果
后端验证完成后,将验证结果返回给客户端。如果验证通过,可以通知客户端解锁购买内容或功能。
本地验证 JSON 数据
如果只想在本地验证 JSON 数据,可以进行如下操作:
1、确保 transactionId 和 productId 符合预期。
2、验证 purchaseDate 是否在合理范围内。
3、校验其他字段,如 originalTransactionId(是否与之前的原始交易匹配)。
尽管如此,这种本地验证只能保证数据格式正确,无法确认数据来源的真实性,因此仍需要通过 Apple 的接口进行后端验证。
扩展知识
后端验证报错
如果在调试时,调用App Store Server API验证,但是返回报错,例如截图中的401报错,可以根据Apple的Get Transaction History文章中,找到对应的报错信息进行处理。
Transaction方法
Transaction除了通过jsonRepresentation获取交易信息外,还有其他的方法。
1、deviceVerification 和 deviceVerificationNonce
deviceVerification 提供设备验证值,表明交易属于当前设备。
deviceVerificationNonce 是一个唯一标识符,用于计算设备验证值。
如果需要验证设备与交易之间的绑定关系,这两个字段是关键。
2、signedDate
表示 App Store 签署交易的日期。
这个字段有助于后端验证交易时间的有效性。
3、退款请求
StoreKit 2 引入了对退款的支持,允许用户直接通过应用发起退款请求(例如 beginRefundRequest 方法)。
如果交易验证通过,可以为用户提供退款入口。
相关文章
1、iOS通过StoreKit2应用收据验证交易:https://fangjunyu.com/2024/12/25/ios%E9%80%9A%E8%BF%87storekit2%E5%BA%94%E7%94%A8%E6%94%B6%E6%8D%AE%E9%AA%8C%E8%AF%81%E4%BA%A4%E6%98%93
2、StoreKit2:https://developer.apple.com/cn/storekit/
3、App Store Server API:https://developer.apple.com/documentation/AppStoreServerAPI
4、Transaction properties:https://developer.apple.com/documentation/storekit/transaction-properties
5、jsonRepresentation:https://developer.apple.com/documentation/storekit/transaction/jsonrepresentation
6、Get Transaction History:https://developer.apple.com/documentation/appstoreserverapi/get-v2-history-_transactionid_