版本记录

版本号

时间

V1.0

2019.01.09

前言

开始

首先看下写作环境

Swift 4.2, iOS 12, Xcode 10

在本教程中,您将了解应用内购买的收据如何工作以及如何验证它们,以确保您的用户已为您提供的商品付款。

付费软件一直存在一个问题,即某些用户试图在不购买软件的情况下使用该软件或欺诈性地访问应用内购买。 收据提供了确认这些购买的工具。 他们通过提供销售记录来实现这一目标。 每当用户购买应用程序,进行应用内购买或更新应用程序时,App Store都会在应用程序包中生成收据。

在本教程中,您将了解这些收据的工作原理以及它们在设备上的验证方式。 在本教程中,您应该熟悉应用内购买和StoreKit。 您将需要一个iOS开发人员帐户,一个用于测试的真实设备,访问iOS开发人员中心和App Store Connect。

What Is a Receipt?

收据包含应用程序包中的单个文件。 该文件采用称为PKCS#7的格式。 这是应用了加密技术的数据的标准格式。 容器包含有效负载(payload),证书链(chain of certificates)和数字签名(digital signature)。 您使用证书链和数字签名来验证Apple是否生成了收据。

有效负载(payload)由一组称为ASN.1的跨平台格式的凭据属性组成。 这些属性中的每一个都包含类型,版本和值(type, version and value)。 这些代表收据的内容。 您的应用使用这些属性来确定收据对设备有效以及用户购买了什么。

Loading the Receipt

打开入门项目。入门项目是支持StoreKit和应用内购买的iPhone应用程序。

要测试收据验证,您必须在真实设备上运行该应用程序,因为它在模拟器中不起作用。您需要开发证书和沙盒帐户。通过XCode测试应用程序时,默认情况下应用程序不会有收据。如果不存在,则starter app会实现请求刷新的证书。

加密代码很复杂,很容易出错。最好使用已知且经过验证的库,而不是尝试编写自己的库。本教程使用OpenSSL库来完成验证加密和解码收据中提供的ASN.1数据的大部分工作。 OpenSSL不是非常Swift友好的,所以在本教程中你将创建一个Swift包装器。

为iPhone编译OpenSSL并不是一个简单的过程。如果您想自己动手,可以在GitHub上找到脚本和说明。入门项目包括OpenSSL文件夹中最新版本的OpenSSL 1.1.1。它被编译为静态库,使修改更加困难。这包括文件夹以及C头文件。该项目还包括使用Swift的OpenSSL库的桥接头。

注意:您可能想知道为什么使用OpenSSL而不是iOS内置的CommonCrypto框架,而且静态OpenSSL库为您的应用程序包添加了大约40MB。 原因是如果用户越狱他们的设备,使用黑客版本替换CommonCrypto将很容易解决这些问题。 bundle中的静态库是一个更难攻击的目标。

入门项目包括一个起始的Receipt类。 它还包含一个静态方法:isReceiptPresent()。 此方法确定是否存在收据文件。 如果没有,它会使用StoreKit在尝试验证之前请求刷新收据。 如果收据不存在,您的应用应该做类似的事情。

打开Receipt.swift。 在类声明结束时为类添加新的自定义初始值设定项:

init() {

guard let payload = loadReceipt() else {

return

}

}

要开始验证,您需要将收据作为Data对象。 将以下新方法添加到init()下面的Receipt以加载收据并返回PKCS#7数据结构:

private func loadReceipt() -> UnsafeMutablePointer? {

// Load the receipt into a Data object

guard

let receiptUrl = Bundle.main.appStoreReceiptURL,

let receiptData = try? Data(contentsOf: receiptUrl)

else {

receiptStatus = .noReceiptPresent

return nil

}

}

此代码获取收据的位置,并尝试将其作为Data对象加载。 如果不存在收据或收据不会作为Data对象加载,则验证失败。 如果在验证收据期间的任何时候检查失败,则整个验证失败。 代码将原因存储在类的receiptStatus属性中。

现在您在Data对象中有了收据,您可以使用OpenSSL处理内容。 OpenSSL函数是用C语言编写的,通常使用指针和其他底层方法。 在loadReceipt()的末尾添加以下代码:

// 1

let receiptBIO = BIO_new(BIO_s_mem())

let receiptBytes: [UInt8] = .init(receiptData)

BIO_write(receiptBIO, receiptBytes, Int32(receiptData.count))

// 2

let receiptPKCS7 = d2i_PKCS7_bio(receiptBIO, nil)

BIO_free(receiptBIO)

// 3

guard receiptPKCS7 != nil else {

receiptStatus = .unknownReceiptFormat

return nil

}

这段代码的工作原理:

1) 要在OpenSSL中使用envelope,首先必须将其转换为BIO,这是OpenSSL使用的抽象I / O结构。要创建一个新的BIO对象,OpenSSL需要一个指向C中原始数据字节的指针。C字节是一个Swift UInt8。由于您可以将任何Sequence和Data表示的数组初始化为UInt8序列,因此只需传入Data实例即可创建[UInt8]数组。然后,您将该数组作为原始字节指针传递。这是可能的,因为Swift隐式桥接函数参数,创建指向数组元素的指针。然后,OpenSSL调用将收据写入BIO结构。

2) 您将BIO对象转换为名为receiptPKCS7的OpenSSL PKCS7数据结构。完成后,您不再需要BIO对象并可以释放先前为其分配的内存。

3) 如果出现任何问题,那么receiptPKCS7将是一个没有指向或指向nil的指针。在这种情况下,请设置状态以反映验证失败。

接下来,您需要确保容器包含签名和数据。将以下代码添加到loadReceipt()方法的末尾以执行这些检查:

// Check that the container has a signature

guard OBJ_obj2nid(receiptPKCS7!.pointee.type) == NID_pkcs7_signed else {

receiptStatus = .invalidPKCS7Signature

return nil

}

// Check that the container contains data

let receiptContents = receiptPKCS7!.pointee.d.sign.pointee.contents

guard OBJ_obj2nid(receiptContents?.pointee.type) == NID_pkcs7_data else {

receiptStatus = .invalidPKCS7Type

return nil

}

return receiptPKCS7

C通常使用结构体处理复杂数据。 与Swift结构不同,C结构仅包含没有方法或其他元素的数据。 对C中结构的引用是对内存位置的引用 - 指向数据结构的指针。

存在各种UnsafePointer类型以允许混合Swift和C代码。 OpenSSL函数需要一个指针,而不是您可能更熟悉的Swift类和结构。 receiptPKCS7是指向保存PKCS#7包络的数据结构的指针。 UnsafePointer的pointee属性遵循指向数据结构的指针。

引用C中指针指向的过程通常足以拥有一个特殊的运算符- >。 指针的pointee属性在Swift中执行此引用。

如果检查成功,则该方法返回指向结构体的指针。 现在您的envelope格式正确且包含数据,您应该验证Apple是否已对其进行签名。

Validating Apple Signed the Receipt

PKCS#7容器使用具有两个组件的公钥加密。 一个组件是与每个人共享的公钥。 第二个是私人安全密钥。 Apple可以使用私钥对数据进行数字签名,因此任何拥有相应公钥的人都可以确保拥有私钥的人进行签名。

对于收据,Apple使用其私钥对收据进行签名,并使用Apple的公钥进行验证。 证书包含有关这些密钥的信息。

通常使用证书来签署构成证书链的其他证书。 这样做可以降低损害任何一个证书的风险,因为它只影响链中较低的证书。 这允许链顶部的单个根证书验证签名和中间证书,而无需由根证书直接签名。

OpenSSL可以为您处理此检查。 在init()的末尾添加以下调用:

guard validateSigning(payload) else {

return

}

现在在Receipt末尾添加一个新方法来时执行检查:

private func validateSigning(_ receipt: UnsafeMutablePointer?) -> Bool {

guard

let rootCertUrl = Bundle.main

.url(forResource: "AppleIncRootCertificate", withExtension: "cer"),

let rootCertData = try? Data(contentsOf: rootCertUrl)

else {

receiptStatus = .invalidAppleRootCertificate

return false

}

let rootCertBio = BIO_new(BIO_s_mem())

let rootCertBytes: [UInt8] = .init(rootCertData)

BIO_write(rootCertBio, rootCertBytes, Int32(rootCertData.count))

let rootCertX509 = d2i_X509_bio(rootCertBio, nil)

BIO_free(rootCertBio)

}

此代码从bundle加载Apple的根证书并将其转换为BIO对象。 请注意,不同的函数调用反映您正在加载X.509格式证书而不是PKCS容器。 添加以下代码以完成validateSigning(_ :):

// 1

let store = X509_STORE_new()

X509_STORE_add_cert(store, rootCertX509)

// 2

OPENSSL_init_crypto(UInt64(OPENSSL_INIT_ADD_ALL_DIGESTS), nil)

// 3

let verificationResult = PKCS7_verify(receipt, nil, store, nil, nil, 0)

guard verificationResult == 1 else {

receiptStatus = .failedAppleSignature

return false

}

return true

这段代码的工作原理:

1) 使用OpenSSL创建X.509证书库。 该库是用于验证的证书的容器。 代码将加载的根证书添加到库。

2) 初始化OpenSSL以进行证书验证。

3) 使用PKCS7_verify(_:_:_:_:_:_ :)从签署收据的根证书中验证链中的证书。 如果是,则该函数返回1。任何其他值表示该envelope未由Apple签名,因此验证失败。

Reading Data in the Receipt

验证Apple签署了收据后,您现在可以阅读收据内容。 如前所述,有效载荷(payload)的内容是一组ASN.1值。 您将使用读取此格式的OpenSSL函数。

Receipt已包含存储payload内容的属性。 在init()的末尾添加以下代码:

readReceipt(payload)

在loadReceipt()之后添加以下方法以开始读取收据数据:

private func readReceipt(_ receiptPKCS7: UnsafeMutablePointer?) {

// Get a pointer to the start and end of the ASN.1 payload

let receiptSign = receiptPKCS7?.pointee.d.sign

let octets = receiptSign?.pointee.contents.pointee.d.data

var ptr = UnsafePointer(octets?.pointee.data)

let end = ptr!.advanced(by: Int(octets!.pointee.length))

}

此代码从PKCS7结构获取指向有效负载起点的指针 - 作为ptr。 然后,您将指针放在有效负载的末尾。 将以下代码添加到readReceipt(_ :)以开始解析有效内容:

var type: Int32 = 0

var xclass: Int32 = 0

var length: Int = 0

ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))

guard type == V_ASN1_SET else {

receiptStatus = .unexpectedASN1Type

return

}

存储有关每个ASN.1对象的信息有三个变量。 ASN1_get_object(_:_:_:_:_ :)读取缓冲区以获取第一个对象。指针更新到下一个对象。

C函数通常使用指向变量的指针从函数返回多个值,并直接更新这些对象。这类似于Swift中的inout参数。 &符号获取指向对象的指针。该函数返回数据的长度(length),ASN.1对象类型(type)和ASN.1 tag值(xclass)。

最后一个参数是要读取的最长长度。提供此功能可防止因读取超出存储区末尾而导致的安全问题。

然后验证有效内容中第一个项的类型是否为ASN.1集。如果不是,则有效载荷无效。否则,您可以开始阅读该集的内容。您将对ASN1_get_object(_:_:_:_:_ :)使用类似的调用来读取有效负载中的所有数据。 ASN1Helpers.swift包含几个辅助方法,它们将收据中的ASN.1数据类型读取为可以为空的Swift值。在readReceipt(_ :)的末尾添加此代码:

// 1

while ptr! < end {

// 2

ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))

guard type == V_ASN1_SEQUENCE else {

receiptStatus = .unexpectedASN1Type

return

}

// 3

guard let attributeType = readASN1Integer(ptr: &ptr, maxLength: length) else {

receiptStatus = .unexpectedASN1Type

return

}

// 4

guard let _ = readASN1Integer(ptr: &ptr, maxLength: ptr!.distance(to: end)) else {

receiptStatus = .unexpectedASN1Type

return

}

// 5

ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))

guard type == V_ASN1_OCTET_STRING else {

receiptStatus = .unexpectedASN1Type

return

}

// Insert attribute reading code

}

这段代码的作用:

1) 创建一个循环,直到指针到达有效负载的末尾。 那时你已经处理了整个有效载荷。

2) 检查对象是否为序列。 每个属性都是三个字段的序列:type, version, data。

3) 获取您将很快使用的属性类型 - 整数。

4) 读取属性版本,整数。 您不需要它进行收据验证。

5) 检查下一个值是否为字节序列。

和以前一样,如果任何值不符合预期,则设置状态代码并且验证失败。

您现在拥有有关当前属性的信息。 您还具有数据类型和指向此属性的数据的指针。 Appledocuments the attributes in a receipt。

您将使用switch语句来处理收据中找到的属性类型。 使用以下内容替换// Insert attribute reading code here注释:

switch attributeType {

case 2: // The bundle identifier

var stringStartPtr = ptr

bundleIdString = readASN1String(ptr: &stringStartPtr, maxLength: length)

bundleIdData = readASN1Data(ptr: ptr!, length: length)

case 3: // Bundle version

var stringStartPtr = ptr

bundleVersionString = readASN1String(ptr: &stringStartPtr, maxLength: length)

case 4: // Opaque value

let dataStartPtr = ptr!

opaqueData = readASN1Data(ptr: dataStartPtr, length: length)

case 5: // Computed GUID (SHA-1 Hash)

let dataStartPtr = ptr!

hashData = readASN1Data(ptr: dataStartPtr, length: length)

case 12: // Receipt Creation Date

var dateStartPtr = ptr

receiptCreationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)

case 17: // IAP Receipt

print("IAP Receipt.")

case 19: // Original App Version

var stringStartPtr = ptr

originalAppVersion = readASN1String(ptr: &stringStartPtr, maxLength: length)

case 21: // Expiration Date

var dateStartPtr = ptr

expirationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)

default: // Ignore other attributes in receipt

print("Not processing attribute type: \(attributeType)")

}

// Advance pointer to the next item

ptr = ptr!.advanced(by: length)

此代码使用每个属性的类型来调用适当的辅助函数,该函数将值放入类的属性中。 读取每个值后,最后一行将指针前进到下一个属性的开头,然后继续循环。

Reading In-App Purchases

应用内购买的属性需要更复杂的处理。 应用内购买不是单个整数或字符串,而是此集合中的另一个ASN.1集。 IAPReceipt.swift包含一个用于存储内容的IAPReceipt。 该集的格式与包含它的格式相同,并且读取它的代码非常相似。 将以下初始化程序添加到IAPReceipt:

init?(with pointer: inout UnsafePointer?, payloadLength: Int) {

let endPointer = pointer!.advanced(by: payloadLength)

var type: Int32 = 0

var xclass: Int32 = 0

var length = 0

ASN1_get_object(&pointer, &length, &type, &xclass, payloadLength)

guard type == V_ASN1_SET else {

return nil

}

while pointer! < endPointer {

ASN1_get_object(&pointer, &length, &type, &xclass, pointer!.distance(to: endPointer))

guard type == V_ASN1_SEQUENCE else {

return nil

}

guard let attributeType = readASN1Integer(ptr: &pointer,

maxLength: pointer!.distance(to: endPointer))

else {

return nil

}

// Attribute version must be an integer, but not using the value

guard let _ = readASN1Integer(ptr: &pointer,

maxLength: pointer!.distance(to: endPointer))

else {

return nil

}

ASN1_get_object(&pointer, &length, &type, &xclass, pointer!.distance(to: endPointer))

guard type == V_ASN1_OCTET_STRING else {

return nil

}

switch attributeType {

case 1701:

var p = pointer

quantity = readASN1Integer(ptr: &p, maxLength: length)

case 1702:

var p = pointer

productIdentifier = readASN1String(ptr: &p, maxLength: length)

case 1703:

var p = pointer

transactionIdentifer = readASN1String(ptr: &p, maxLength: length)

case 1705:

var p = pointer

originalTransactionIdentifier = readASN1String(ptr: &p, maxLength: length)

case 1704:

var p = pointer

purchaseDate = readASN1Date(ptr: &p, maxLength: length)

case 1706:

var p = pointer

originalPurchaseDate = readASN1Date(ptr: &p, maxLength: length)

case 1708:

var p = pointer

subscriptionExpirationDate = readASN1Date(ptr: &p, maxLength: length)

case 1712:

var p = pointer

subscriptionCancellationDate = readASN1Date(ptr: &p, maxLength: length)

case 1711:

var p = pointer

webOrderLineId = readASN1Integer(ptr: &p, maxLength: length)

default:

break

}

pointer = pointer!.advanced(by: length)

}

}

与读取初始集的代码的唯一区别来自应用内购买中发现的不同类型值。 如果在初始化的任何时刻它发现了一个意外的值,它返回nil并停止。

回到Receipt.swift,用以下内容替换case 17: // IAP Receipt in readReceipt(_:)以使用新对象:

case 17: // IAP Receipt

var iapStartPtr = ptr

let parsedReceipt = IAPReceipt(with: &iapStartPtr, payloadLength: length)

if let newReceipt = parsedReceipt {

inAppReceipts.append(newReceipt)

}

您将当前指针传递给init()以读取包含IAP的集合。 如果返回有效的收据项,则会将其添加到阵列中。 请注意,对于耗材和非续订订阅(consumable and non-renewing subscriptions),应用内购买仅在购买时出现一次。 它们未包含在将来的收据更新中。 非消费品和自动续订订阅(Non-consumable and auto-renewing subscriptions)将始终显示在收据中。

Validating the Receipt

读取收据有效负载后,您可以完成验证收据。 将此代码添加到Receipt中的init():

validateReceipt()

添加一个新方法到Receipt

private func validateReceipt() {

guard

let idString = bundleIdString,

let version = bundleVersionString,

let _ = opaqueData,

let hash = hashData

else {

receiptStatus = .missingComponent

return

}

}

此代码确保收据包含验证所需的元素。 如果缺少任何内容,则验证失败。 在validateReceipt()的末尾添加以下代码:

// Check the bundle identifier

guard let appBundleId = Bundle.main.bundleIdentifier else {

receiptStatus = .unknownFailure

return

}

guard idString == appBundleId else {

receiptStatus = .invalidBundleIdentifier

return

}

此代码获取应用程序的包标识符,并将其与收据中的包标识符进行比较。 如果它们不匹配,则收据可能是从另一个应用程序复制而无效。

验证标识符后添加以下代码:

// Check the version

guard let appVersionString =

Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else {

receiptStatus = .unknownFailure

return

}

guard version == appVersionString else {

receiptStatus = .invalidVersionIdentifier

return

}

您可以将收据中存储的版本与应用的当前版本进行比较。 如果值不匹配,则收据可能是从应用程序的其他版本复制的,因此应使用应用程序更新收据。

最终验证检查验证是否为当前设备创建了收据。 要执行此操作,您需要设备标识符,这是一个字母数字字符串,可为您的应用唯一标识设备。

将以下方法添加到Receipt:

private func getDeviceIdentifier() -> Data {

let device = UIDevice.current

var uuid = device.identifierForVendor!.uuid

let addr = withUnsafePointer(to: &uuid) { (p) -> UnsafeRawPointer in

UnsafeRawPointer(p)

}

let data = Data(bytes: addr, count: 16)

return data

}

此方法将设备标识符作为Data对象获取。

您使用hash函数验证设备。 哈希函数很容易在一个方向上计算,但很难逆转。 哈希通常用于允许确认值而无需存储值本身。 例如,密码通常存储为hash值而不是实际密码。 可以将多个值一起散列,如果最终结果相同,您可以确信原始值是相同的。

在Receipt类的末尾添加以下方法:

private func computeHash() -> Data {

let identifierData = getDeviceIdentifier()

var ctx = SHA_CTX()

SHA1_Init(&ctx)

let identifierBytes: [UInt8] = .init(identifierData)

SHA1_Update(&ctx, identifierBytes, identifierData.count)

let opaqueBytes: [UInt8] = .init(opaqueData!)

SHA1_Update(&ctx, opaqueBytes, opaqueData!.count)

let bundleBytes: [UInt8] = .init(bundleIdData!)

SHA1_Update(&ctx, bundleBytes, bundleIdData!.count)

var hash: [UInt8] = .init(repeating: 0, count: 20)

SHA1_Final(&hash, &ctx)

return Data(bytes: hash, count: 20)

}

您计算SHA-1哈希以验证设备。 OpenSSL库再次可以计算您需要的SHA-1哈希值。 您可以组合收据中的不透明值,收据中的包标识符和设备标识符。 Apple在购买时了解这些值,您的应用在验证时就知道这些值。 通过计算哈希并检查收据中的哈希,您验证是否为当前设备创建了收据。

将以下代码添加到validateReceipt()的末尾:

// Check the GUID hash

let guidHash = computeHash()

guard hash == guidHash else {

receiptStatus = .invalidHash

return

}

此代码将计算的哈希值与收据中的值进行比较。 如果它们不匹配,则收据可能是从其他设备复制的,并且无效。

收据的最终检查仅适用于允许批量购买计划(Volume Purchase Program - VPP)购买的应用程序。 这些购买包括收据中的到期日期。 添加以下代码以完成validateReceipt():

// Check the expiration attribute if it's present

let currentDate = Date()

if let expirationDate = expirationDate {

if expirationDate < currentDate {

receiptStatus = .invalidExpired

return

}

}

// All checks passed so validation is a success

receiptStatus = .validationSuccess

如果存在非nil的到期日期,那么您的应用应检查到期日是否在当前日期之后。如果它在当前日期之前,则收据不再有效。如果不存在过期日期,则验证不会失败。

最后,在完成所有这些检查而没有任何失败的情况下,您可以将收据标记为有效。

Running the App

运行该应用程序。您必须在真实设备上运行此项目。存储相关代码在模拟器中不起作用。您还需要一个沙盒帐户设置。在App Store购买的应用程序中,将出现收据。但是在从XCode进行测试时,您需要刷新才能获得收据。教程应用程序已经这样做了。您需要登录。然后,应用程序将使用本教程中的代码验证收据并显示收据。

完成此操作后,添加应用内购买。确保还使用产品标识符(product identifiers)更新ViewController.swift。使用Buy IAP按钮和沙盒帐户。您会看到table view列出了这些应用内购买。还可以尝试消费品购买,并记下刷新收据后它们是如何消失的。

Protecting Receipt Validation Code

攻击者将努力绕过您的收据验证码(receipt validation code)。 使用此或任何其他收据验证码无需更改会产生风险。 如果攻击者可以在一个使用此确切代码的应用程序中绕过检查,则攻击者可以使用相同的代码更轻松地为另一个应用程序重复此过程。 对于高价值或高盈利的应用程序,您需要在保持相同工作的同时修改本教程的代码。

为了防止绕过验证过程,您可以重复执行验证而不是一次。 避免显式错误消息(例如“收据验证失败”)会使攻击者的工作更加困难。 将失败代码放置在远离验证检查的应用程序部分中也会使攻击者的工作更加困难。

最后,您需要平衡未经授权访问您的应用程序的风险与额外的时间和复杂性,代码的额外混淆会增加您的开发过程。

后记

本篇主要讲述了收据验证,感兴趣的给个赞或者关注~~~

ios 凭据验证_iOS内购IAP(十四) —— IAP的收据验证(一)相关推荐

  1. ios 内购正式环境_iOS内购-部分玩家无法恢复购买

    起因是这样,自去年12月份,就陆续有玩家反馈以下问题购买了商品,却无法获得,也无法恢复购买 兑换码无法兑换到商品 重现:在设备1上兑换了A商品,恢复购买和再次免费购买,是无效的,而在设备2上用同一个账 ...

  2. iOS开发支付篇——内购(IAP)详解

    iOS开发支付篇--内购(IAP)详解 苹果客户端购买虚拟商品是需要走内购 先看gif图 具体步骤: 协议.税务和银行业务 信息填写 内购商品的添加 添加沙盒测试账号 内购代码的具体实现 内购的注意事 ...

  3. ios内购二次验证安全性问题_iOS 内购遇到的坑

    一.内购沙盒测试账号在支付成功后,再次购买相同 ID 的物品,会提示如下内容的弹窗.您以购买过此APP内购项目,此项目将免费恢复 您以购买过此APP内购项目,此项目将免费恢复.PNG 原因: 当使用内 ...

  4. ios内购二次验证安全性问题_iOS内购之二次验证

    开篇:关于iOS内购整体流程网上能找到很多.我抽丝剥茧,着重说一下二次验证及收据回传的数据问题. 二次验证 关于二次验证,其实有两种做法,第一种是在app端验证,第二种也是安全防盗的一种,在服务端进行 ...

  5. ios 内购正式环境_iOS 内购最新讲解

    本文为CocoaChina网友IIronMan投稿 一.总说内购的内容协议.税务和银行业务 信息填写 内购商品的添加 添加沙盒测试账号 内购代码的具体实现 内购的注意事项 二.协议.税务和银行业务 信 ...

  6. iOS快速上手应用内购(IAP)附Demo

    前言:最近项目中接触到内购,本文主要介绍如何开发应用内购(In App Purchase),有一些是根据实际需求做的考虑,有不同的见解欢迎留言指教~本文demo:https://github.com/ ...

  7. ios 内购正式环境_iOS 内购的实现

    自己开发的视频直播项目,牵涉到充值金币,用到了苹果公司的内购,趴坑了两天,这里总结下实现苹果内购. 一. 创建测试App 首先你需要登录 App的ItunesConnection,你会看到如下界面 简 ...

  8. ios 内购正式环境_iOS 内购经验

    最近在做iOS内购,碰到的一些问题及解决方案,希望能帮到大家 1.如果需要内购实现代码,请留下你的QQ 2.关于内购商品被退回 内购被退回,主要有两个原因, 一是标题描述等信息没写清楚,这里需要你填写 ...

  9. java ios内购凭证_iOS内购看我就够了(含代码)

    导入头文件#import 遵循协议 [[SKPaymentQueue defaultQueue] addTransactionObserver:self];// 4.设置支付服务 -(void)sta ...

  10. ios 内购正式环境_ios内购之服务端操作

    { "status": 0, "environment": "Sandbox", "receipt": { " ...

最新文章

  1. svchost服务(DLL服务)
  2. 需求文档可以不签字吗? 之一
  3. 第三次作业:阅读《构建之法》1-5章有感
  4. java资源争夺_所有满足类似需求,争夺同类资源的组织和个人统称为(   )。...
  5. 周鸿祎:在360新员工入职培训上的讲话
  6. LeetCode meituan-007. 小团的选调计划(模拟)
  7. Node.js: 如何继承 events 自定义事件及触发函数
  8. ipvs学习笔记(二)
  9. 怎样在spyder中暂停程序的运行
  10. 难得干货,揭秘支付宝的2维码扫码技术优化实践之路
  11. List集合序列排序的两种方法
  12. Android ADB 环境变量配置
  13. 微信小程序,实现一个简易的新闻网
  14. 使用 Parity 建立Proof-of-Authority (PoA) Ethereum Chain
  15. c语言编写坦克大战设计报告,c语言编写坦克大战源代码
  16. ZwSe2团队共识V0.1
  17. 类别不均衡问题之loss大集合:focal loss, GHM loss, dice loss 等等
  18. 物联网云平台应用于水产养殖
  19. 浏览器扩展——pagenote(网页笔记)
  20. 十年踪迹:巧用 currentColor 属性来实现自定义 checkbox 样式

热门文章

  1. java pdf 加图片_java实现在pdf模板的指定位置插入图片
  2. 如何开启显示文件后缀名(扩展名)
  3. 成功三步曲:有勇、有谋、有德
  4. 程序员如何高效准备简历和面试06:使用STAR法则表现自己
  5. html项目答辩策划书,职业生涯规划大赛答辩会策划书
  6. OpenCV 3 image shape - size - dtype
  7. Photoshop教程:超全的PS快捷键大全分享
  8. Latex学习笔记 (5) 通用长度单位
  9. 基于Jsoup的简单JAVA爬虫 人民币汇率中间价
  10. 行人重识别论文阅读8-FastReID京东快速行人重识别