iOS通过StoreKit2的Transaction实现后端验证交易
iOS通过StoreKit2的Transaction实现后端验证交易

iOS通过StoreKit2的Transaction实现后端验证交易

之前写过一篇关于《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:确认用户购买了正确的商品。

transactionIdoriginalTransactionId:检查是否重复(防止双重消费)。

currencyprice:验证金额是否正确。

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_

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注