苹果内购-WWDC

Jingxc大约 9 分钟payjava后端payjavaapplewwdc后端

苹果内购-WWDC

App Store Server API

苹果提供了以下这些 Server API

API简介


查询用户订单的收据

GET https://api.storekit.itunes.apple.com/inApps/v1/lookup/{orderId}

Look Up Order IDopen in new window : 使用订单ID从收据中获取用户的应用内购买项目收据信息。

查询用户历史收据

GET https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}

Get Transaction Historyopen in new window : 获取用户在您的 app 的应用内购买交易历史记录。

查询用户内购退款

GET https://api.storekit.itunes.apple.com/inApps/v1/refund/lookup/{originalTransactionId}

Get Refund Historyopen in new window : 获取 app 中为用户退款的所有应用内购买项目的列表。

查询用户订阅项目状态

GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}

Get All Subscription Statusesopen in new window : 获取您 app 中用户所有订阅的状态。

提交防欺诈信息

PUT https://api.storekit.itunes.apple.com/inApps/v1/transactions/consumption/{originalTransactionId}

Send Consumption Informationopen in new window : 当用户申请退款时,苹果通知(CONSUMPTION_REQUEST)开发者服务器,开发者可在12小时内,提供用户的信息(比如游戏金币是否已消费、用户充值过多少钱、退款过多少钱等),最后苹果收到这些信息,协助“退款决策系统” 来决定是否允许用户退款。

延长用户订阅的时长

PUT https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/extend/{originalTransactionId}

Extend a Subscription Renewal Dateopen in new window : 使用原始交易标识符延长用户有效订阅的续订日期。(相当于免费给用户增加订阅时长)

接口参数说明


App Store Server API 是苹果提供给开发者,通过服务器来管理用户在 App Store 应用内购买的一套接口(REST API)。

线上环境的 URL:

https://api.storekit.itunes.apple.com/

沙盒环境测试:

https://api.storekit-sandbox.itunes.apple.com/

JWT 简介:

调用这些 API 需要 JWT(JSON Web Token)进行授权。那么什么是 JWT 呢?

JWT 是一个开放式标准(规范文件 RFC 7519),用于在各方之间以 JSON 对象安全传输信息。有两种实现,一种基于 JWS 的实现使用了BASE64URL编码和数字签名的方式对传输的Claims提供了完整性保护,也就是仅仅保证传输的Claims内容不被篡改,但是会暴露明文。另一种是基于 JWE 实现的依赖于加解密算法、BASE64URL编码和身份认证等手段提高传输的Claims内容被破解的难度。

  • JWS(规范文件 RFC 7515): JSON Web Signature,表示使用 JSON 数据结构和 BASE64URL 编码表示经过数字签名或消息认证码(MAC)认证的内容。
  • JWE(规范文件 RFC 7516): JSON Web Encryption,表示基于 JSON 数据结构的加密内容。

目前苹果 JWT 相关的内容,都是基于 JWS 实现,所以下文的 JWT 默认指 JWS。

JWT(JWS) 由三部分组成:

  • header:主要声明了 JWT 的签名算法;
  • payload:主要承载了各种声明并传递明文数据;
  • signture:拥有该部分的 JWT 被称为 JWS,也就是签了名的 JWS。

组装 JWT

知道了基本的 JWT 知识,我们就可以开工啦。要生成签名的 JWT 有三步:

  1. 创建 JWT 标头。
  2. 创建 JWT 有效负载。
  3. 在 JWT 上签名。

JWT header 示例:

{
    "alg": "ES256",
    "kid": "2X9R4HXF34",
    "typ": "JWT"
}

JWT payload 示例:

{
  "iss": "57246542-96fe-1a63e053-0824d011072a",
  "iat": 1623085200,
  "exp": 1623086400,
  "aud": "appstoreconnect-v1",
  "nonce": "6edffe66-b482-11eb-8529-0242ac130003",
  "bid": "com.example.testbundleid2021"
}

以上是苹果要求的字段规范,所以不同的 JWT 字符和内容并一样,所以,我们看看苹果对这些字段的定义:

字段字段说明字段值说明
algEncryption Algorithm,加密算法默认值:ES256。App Store Server API 的所有 JWT 都必须使用 ES256 加密进行签名。
kidKey ID,密钥ID您的私钥ID,值来自 App Store Connect,下文会讲解。
typToken Type,令牌类型默认值:JWT
issIssuer,发行人您的发卡机构ID,值来自 App Store Connect 的密钥页面,下文会讲解。
iatIssued At,发布时间秒,以 UNIX 时间(例如:1623085200)发布令牌的时间
expExpiration Time,到期时间秒,令牌的到期时间,以 UNIX 时间为单位。在iat中超过 60 分钟过期的令牌无效(例如:1623086400)
audAudience,受众固定值:appstoreconnect-v1
nonceUnique Identifier,唯一标识符您仅创建和使用一次的任意数字(例如: "6edffe66-b482-11eb-8529-0242ac130003")。可以理解为 UUID 值。
bidBundle ID,套装ID您的 app 的套装ID(例如:"com.example.testbundleid2021")

其中 kidiss 值是从 App Store Connect 后台创建和获取。

生成密钥 ID(kid)

要生成密钥,您必须在 App Store Connect 中具有管理员角色或帐户持有人角色。登录 App Store Connect 并完成以下步骤:

  1. 选择 “用户和访问”,然后选择 “密钥” 子标签页。
  2. 在 “密钥类型” 下选择 “App内购买项目”。
  3. 单击 “生成API内购买项目密钥”(如果之前创建过,则点击 “添加(+)” 按钮新增。)。
  4. 输入密钥的名称。该名称仅供您参考,名字不作为密钥的一部分。
  5. 单击 “生成”。
用户及访问
用户及访问
密钥
密钥

生成的密钥,有一列名为 “密钥 ID” 就是 kid 的值,鼠标移动到文字就会显示 拷贝密钥 ID,点击按钮就可以复制 kid 值。

生成 Issuer(iss) 同理,iss 值的生成,类似:

issuer ID 值就是 iss 的值。

issuer
issuer

生成和签名 JWT

获取到这里参数后,就需要签名,那么还需要签名的密钥文件。

下载并保存密钥文件

App Store Connect 密钥文件,在刚才生成 kid时,列表右边有 下载 App 内购买项目密钥 按钮(仅当您尚未下载私钥时,才会显示下载链接。):

密钥文件
密钥文件

此私钥只能一次性下载!

另外 Apple 不保留私钥的副本,将您的私钥存放在安全的地方。

提示

注意:将您的私钥存放在安全的地方。不要共享密钥,不要将密钥存储在代码仓库中,不要将密钥放在客户端代码中。如果您怀疑私钥被盗,请立即在 App Store Connect 中撤销密钥。有关详细信息,请参阅 撤销API密钥。

密钥文件
密钥文件

相关信息

API密钥有两个部分:苹果保留的公钥和您下载的私钥。开发者使用私钥对授权 API 在 App Store 中访问数据的令牌进行签名。

需要注意的是,App Store Server API 密钥是 App Store Server API 所独有的,不能用于其他 Apple 服务(比如 Sign in with Apple 服务或 App Store Connet API 服务等。)。

生成请求令牌


package com.game.server.util;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

public class JwtUtils {

    /**
     * 通过私钥生成一个JWT的token 用于以后解析验证
     *
     * @param issuer
     * @param aud
     * @param kid
     * @param bid
     * @return
     */
    public static String createJWT(String issuer, String aud, String kid, String bid, String privateKey) {

        // 指定签名的时候使用的签名算法,也就是header那部分,jjwt已经将这部分内容封装好了。
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.ES256;

        // 生成JWT的时间
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);

        // 创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的)
        Map<String, Object> claims = new HashMap<>();
        claims.put("alg", "ES256");
        claims.put("typ", "JWT");
        claims.put("kid", kid);
        long expMillis = nowMillis + 3600000;
        Date expDate = new Date(expMillis);
        // 生成签名的时候使用的秘钥secret,切记这个秘钥不能外露哦。它就是你服务端的私钥,在任何场景都不应该流露出去。
        // 一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
        // 下面就是在为payload添加各种标准声明和私有声明了
        JwtBuilder builder = Jwts.builder() // 这里其实就是new一个JwtBuilder,设置jwt的body
                .setHeader(claims) // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
                .claim("nonce", UUID.randomUUID().toString())// 设置唯一标志符
                .claim("bid", bid)// Bundle ID,套装ID app
                                  // 的套装ID(例如:“com.example.testbundleid2021”)
                .setIssuedAt(now) // iat: jwt的签发时间
                .setIssuer(issuer) // issuer:jwt签发人
                .setAudience(aud) // 接收jwt的一方
                .setExpiration(expDate) // 到期时间
                .signWith(signatureAlgorithm, KeyPairFromPEM(privateKey).getPrivate()); // 设置签名使用的签名算法和签名使用的秘钥
        return builder.compact();
    }

    public static KeyPair KeyPairFromPEM(String privateKeyPEM) {

        // Remove headers and line breaks from the PEM string
        privateKeyPEM = privateKeyPEM.replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "").replaceAll("\\s", "");

        // Decode the Base64-encoded PEM content
        byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyPEM);

        // Generate the PrivateKey
        PrivateKey privateKey = null;
        try {
            // Create a KeyFactory for ECDSA
            KeyFactory keyFactory = KeyFactory.getInstance("EC");

            // Generate the PKCS8EncodedKeySpec from the private key bytes
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);

            privateKey = keyFactory.generatePrivate(keySpec);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        } catch (InvalidKeySpecException e) {
            e.printStackTrace();
        }

        // Generate the PublicKey from the private key (you can derive it if
        // needed)
        // PublicKey publicKey = keyFactory.generatePublic(new
        // X509EncodedKeySpec(privateKey.getEncoded()));

        KeyPair keyPair = new KeyPair(null, privateKey); // For private key only

        // Now you have the KeyPair
        return keyPair;
    }

    public static void main(String[] args) throws IOException {
        String inputFilePath = "/Users/jingxc/service/sts-project/jj_oversea_jjworld_game/jj_oversea_jjworld_payment/src/main/resources/AppleRootCA-G3.cer"; // Replace
        // with
        // your
        // input
        // .cer file path
        String outputFilePath = "./output.pem"; // Replace with your
                                                // desired output .pem
                                                // file path

        try (FileInputStream cerInputStream = new FileInputStream(inputFilePath);
                FileOutputStream pemOutputStream = new FileOutputStream(outputFilePath)) {

            CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
            X509Certificate cert = (X509Certificate) certFactory.generateCertificate(cerInputStream);

            String certPEM = "-----BEGIN CERTIFICATE-----\n" + Base64.getEncoder().encodeToString(cert.getEncoded())
                    + "\n" + "-----END CERTIFICATE-----\n";

            pemOutputStream.write(certPEM.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
  • KeyPairFromPEM:方法是将密钥转换成创建token是需要的KeyPair
  • createJWT:方法是创建token的主要方法
  • main:方法给出了将苹果的cer证书转换成pem证书

说明

最后在调用苹果端点

https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}

时返回数据出现了信息极少的情况,如下

{
    "revision":"0",
    "bundleId":"com.jjlmzh.global.ios",
    "appAppleId":1628482223,
    "environment":"Production",
    "hasMore":false,
    "signedTransactions":[

    ]
}

提示

实践中遇到了 消耗性票据 获取不正确的问题 支付票据(receipt)中 收据列表(in_app)会保留所有 订阅类商品、非消耗性商品 信息,且会依次进入列表(最后一位是最新的一次购买记录)。消耗性商品 信息只在未向苹果服务器进行校验时存在,且只存在列表第一项(再次购买 消耗性商品 会替换票据信息)。

后续如果有更新会持续补充...

参考

上次编辑于:
贡献者: Jingxc