[TOC]
In-App是什么室抽?
全稱:Apple Pay In-App Provisioning。就是在應用內(nèi)配置信息,把用戶的銀行卡直接綁定到用戶手機的Apple Wallet中,而不需要用戶手動輸入信息,提供了良好的用戶體驗乔妈。無需跳出應用,直接把用戶信息通過Passkit SDK提供給蘋果氓皱,達到綁卡的目的路召。 starts and finishes 整個流程都是在App中。
我們要做的波材,也就是ApplePay的應用內(nèi)綁卡功能股淡。和使用AppleWallet綁卡是一樣的,綁卡后可以通過ApplePay購物支付等廷区。
綁卡流程
關鍵角色
- Bank Client:對接用戶
- Bank Server:提供用戶/卡信息
- PKPass:提供Apple Wallet相關查詢/綁卡接口
- Apple Server:接收PKPass數(shù)據(jù)
- Visa:移動支付運營商(PNO:Payment Network Operator)唯灵。
- FD:卡信息提供商,負責發(fā)卡
解釋一下Visa與FD他們的區(qū)別:
Visa是支付運營商躲因,他們定義了一套支付的協(xié)議早敬,不同的銀行可以對接其協(xié)議,達成支付能力大脉。他們會給每個銀行一個編號搞监,支付過程首先流轉到Visa,再根據(jù)編號識別屬于什么銀行镰矿,然后做數(shù)據(jù)流轉琐驴。
類似的還有美國運通,
FD是卡信息提供商秤标,Bank的卡片生成也是FD生成的绝淡,包括cvv2,日期苍姜,卡號等信息牢酵。支付過程中卡信息的確認也發(fā)生在FD。
Bank銀行負責記錄賬戶與卡之間的關系衙猪,卡余額也是記錄在Bank方馍乙。
關鍵短句
- DPAN:Device Primary Account Number 設備主賬號(與銀行卡號唯一對應的一串號碼布近,如:V-999916888641233333222)
- FPAN:Funding Primary Account Number 資金主賬號(銀行卡號)
- SEID:蘋果手機的一個序列號(NFC模塊的序列號)
- ECC:Elliptic Curve Cryptography(橢圓曲線加密),蘋果綁卡就是經(jīng)過ECC-V2加密傳輸?shù)摹?/li>
- PNO:Payment Network Operator丝格,支付網(wǎng)絡運營商撑瞧,我們目前PNO就是Visa。
- regular provisioning flow:常規(guī)認證流程显蝌。無論是蘋果支付预伺,或華為/小米/谷歌,有的只是加解密過程不一樣曼尊,最終解密完成之后的拿到明文Payload酬诀,執(zhí)行綁定操作。
綁卡流轉流程
蘋果文檔上介紹的涩禀,是單獨的綁卡過程料滥。下面講述的,是我們Bank綁卡的實際流程艾船,包括綁卡入口的判定葵腹。
1. Bank App判斷是否顯示綁卡按鈕。
想要顯示綁卡入口屿岂,需要滿足兩點:
- 設備及系統(tǒng)支持践宴。(iphone6 ios9.0+)
- Bank的綁卡功能開關。Bank添加了開關功能爷怀,基于此項開的情況下阻肩,才去判斷PKPassLibrary。
- 未被添加到當前設備(連接iwatch的情況下运授,需要兩者都被綁定才會不顯示)烤惊。
App向Bank后臺請求ApplePay相關數(shù)據(jù),根據(jù)拿到的DPAN吁朦,放到PKPassLibrary的canAddPaymentPass接口判斷是否已綁定柒室,如已綁定則不展示。
2. Bank用戶點擊Add To Apple Wallet按鈕逗宜,觸發(fā)綁卡流程雄右。
首先,Bank App會向Bank后臺發(fā)起一個請求纺讲,后臺返回啟動綁卡的config信息擂仍,包含的關鍵信息有:
- cardHolderName 持卡人姓名
- paymentNetwork 支付運營商
- primaryAccountNumberSuffix 主賬戶后4位
- primaryAccountIdentifier(DPAN)PassKit可以根據(jù)其判斷是否展示AppleWatch綁卡。
3. App生成Config熬甚,并通過PKAddPaymentPassViewController調(diào)起In-App界面逢渔。
PKAddPaymentPassViewController這個VC的生成有條件限制,不符合條件會返回nil乡括,后面會細說复局。
添加到Wallet入口 | In-App綁卡界面 | 綁卡失敗示例 |
---|---|---|
4. 點擊下一步冲簿,會觸發(fā)PassKit代理,待App上傳交易加密字段
加密發(fā)生在Bank后臺亿昏,這也是蘋果推薦的一種方式,保證了數(shù)據(jù)的安全档礁。
蘋果回調(diào)返回certificates/nonce/nonceSignature角钩,這三個數(shù)據(jù)發(fā)送給Bank后臺,后臺驗證證書鏈的合法性呻澜,如正確递礼,把綁卡相關用戶信息兩重加密,回傳給App羹幸。
App把從Bank后臺收到的encryptedPassData/ephemeralPublicKey/activationData脊髓,經(jīng)base64反解,組裝成PKAddPaymentPassRequest栅受,通過PassKit代理中的handler傳送給蘋果将硝。
5. 蘋果解密ECCV2數(shù)據(jù),并把解密后數(shù)據(jù)K傳給Visa
加解密是整個In-App綁卡中最重要和關鍵的部分屏镊,也是最容易出錯的部分依疼。比較難排查,需要有蘋果人員配合分析日志而芥。
Note:蘋果對于加解密律罢,有一份Test Vector,可以郵件蘋果或者直接給蘋果對接人要棍丐。有了這個Test Vector误辑,后臺就能準確對比加密過程中每一步的結果值,確保加密無誤歌逢。
Note:在我們多次的異常解決過程中巾钉,蘋果給的郵件回復往往能很直接的命中要害,所以要多郵件溝通趋翻。
6. Visa解密K得到原始JSON睛琳,進入常規(guī)認證流程。
encryptedPassData中Payload一般是這個樣子的踏烙,外面再套兩層加密(for Visa师骗,for Apple),也保證了數(shù)據(jù)傳輸?shù)陌踩浴?/p>
{
"primaryAccountNumberPrefix":"xxx626", "encryptedPrimaryAccountNumber":"TUJQxxxxxx1GSy0xxwNjQuMS0tVERFQS1BOEZFOEVGRTdFNzlFN",
"nonceSignature":"xxx089C255A06ExxxF1702BA74715D9xxx1C5CBD7xxxx90A6F06B94ED67D231765D", "networkName":"Visa",
"name":"xxxxxxxxxleseed",
"nonce":"0aa6xxx98"
}
綠色/橙色流程
對于每個綁卡認證請求讨惩,蘋果都會給出對應的風險等級建議辟癌。
- 綠色流程:大部分蘋果給出的建議都是綠色流程,可以直接認證綁卡成功荐捻,不需要其它用戶身份驗證黍少。
- 黃色流程:必須先驗證身用戶身份寡夹,由發(fā)卡行提供驗證選項,具體方式可以通過(SMS/EMAIL/phone-call)
- 橙色流程:需要更嚴格的身份驗證厂置,蘋果推斷出可能存在欺詐行為(Apple賬戶/歷史記錄)菩掏,需要上報反欺詐團隊嚴格驗證。
- 紅色流程:拒絕認證昵济。
蘋果Must條款
- 發(fā)卡行必須支持應用內(nèi)綁卡智绸,包括iPad(提供安全/無縫的用戶體驗)。
- 提交審核資料時访忿,必須附帶應用內(nèi)綁卡的相關視頻瞧栗。
- 不能自定義“Add To Apple Wallet”按鈕,否則蘋果會拒絕海铆。
- 必須支持遠程啟用/禁用功能迹恐。
- 必須支持推送通知(后臺邏輯)。
- 必須支持卡生命周期管理(后臺邏輯)卧斟。
iOS端接入PassKit
1. 提供原生“Add To Apple Wallet”按鈕
因為蘋果不支持自定義按鈕殴边,所以需要把原生按鈕,橋接到Flutter/RN唆涝,具體代碼不再展示找都,遵守其規(guī)則就行。
2. 是否展示“Add To Apple Wallet”按鈕
RN寫在了RNApplePayService廊酣,F(xiàn)lutter寫在了ApplePayFlutterService中能耻,功能包括4個:
- 是否需要展示按鈕
- 是否包含此卡
- 卡片激活狀態(tài)
- 開始綁卡流程
//RNApplePayService
//MARK: 獲取AppleWallet狀態(tài): 0.不支持 1.已綁定完成(iPhone/當前連接iWatch) 2.可綁定
@objc public func appleWalletState(_ primaryAccountIdentifier: String,
_ resolve: RCTPromiseResolveBlock,
_: RCTPromiseRejectBlock) {
/// 檢查是否應該顯示添加到Wallet按鈕
/// @param primaryAccountIdentifier 賬戶標識
guard PKAddPaymentPassViewController.canAddPaymentPass() else {
print("客戶端不能進行ApplePay的設備卡加載")
resolve(0)
return
}
// 從服務器緩存取applePaySwitch狀態(tài)
if (!SingleInstanceSettings.applePaySwitch) {
resolve(0)
return
}
// 從SDK取結果
let library = PKPassLibrary()
let can = library.canAddPaymentPass(withPrimaryAccountIdentifier: primaryAccountIdentifier)
resolve(can ? 2 : 1)
}
2. 綁卡流程
import Foundation
import PassKit
import RxSwift
import XCGLogger
public typealias BankAddToWalletCallback = (_ finished: Bool, _ error: NSError?) -> Void
public class BankApplePayUtil: NSObject, PKAddPaymentPassViewControllerDelegate {
public var callback: BankAddToWalletCallback?
@objc public var cardNo = "" // 需要用戶傳入
@objc public func addToWalletStart() {
BankApplePayAPI.fetchPaymentConfiguration(cardNo: cardNo) { [weak self] result, error in
guard let params = result else {
XCGLogger.default.info("接口返回有誤,請檢查:\(error ?? "")")
return
}
guard let config = self?.parseConfig(params) else {
XCGLogger.default.info("生成PKAddPaymentPassRequestConfiguration失敗")
return
}
// 主線程調(diào)用UI
DispatchQueue.main.async {
guard let addPaymentVC = PKAddPaymentPassViewController(requestConfiguration: config, delegate: self) else {
XCGLogger.default.info("AddPaymentVC生成失敗亡驰,請檢查晓猛!")
return
}
if #available(iOS 13.0, *) {
addPaymentVC.overrideUserInterfaceStyle = .light
}
self?.topVC?.present(addPaymentVC, animated: true, completion: nil)
}
}
}
private var topVC: UIViewController? {
var controller = UIApplication.shared.keyWindow?.rootViewController
if let rootVC = controller {
var presentedController = rootVC.presentedViewController
if let presentVC = presentedController, !presentVC.isBeingDismissed {
controller = presentedController
presentedController = controller?.presentedViewController
}
return controller
}
return controller
}
private func parseConfig(_ params: [String: Any]) -> PKAddPaymentPassRequestConfiguration? {
guard let config = PKAddPaymentPassRequestConfiguration(encryptionScheme: .ECC_V2) else {
XCGLogger.default.info("PKAddPaymentPassRequestConfiguration生成失敗凡辱!")
return nil
}
if #available(iOS 12.0, *) {
config.style = .payment
}
config.cardholderName = params["cardHolderName"] as? String
config.paymentNetwork = PKPaymentNetwork(params["paymentNetwork"] as? String ?? "Visa")
config.primaryAccountSuffix = params["primaryAccountNumberSuffix"] as? String
config.primaryAccountIdentifier = params["primaryAccountIdentifier"] as? String
config.localizedDescription = params["localizedDescription"] as? String
return config
}
// MARK: PKAddPaymentPassViewControllerDelegate
/// 向發(fā)卡方提供證書鏈戒职、nOnce, nOnceSignature等信息
/// 重要:回調(diào)20s未執(zhí)行, 則會視為失敗
/// - Parameters:
/// - controller: VC
/// - certificates: 證書鏈
/// - nonce: nonce
/// - nonceSignature: nonceSignature
/// - handler:
/// - activationData: ?次性加密OTP透乾,?于確保加載請求的安全合法洪燥,由發(fā)卡方生成和驗證(可省略)
/// - encryptedPassData: 數(shù)據(jù)加密后的JSON?件
/// - ephemeralPublicKey: ECC算法使用,發(fā)卡方生成的隨機公鑰
/// - wrappedKey
public func addPaymentPassViewController(_: PKAddPaymentPassViewController,
generateRequestWithCertificateChain certificates: [Data],
nonce: Data,
nonceSignature: Data,
completionHandler handler: @escaping (PKAddPaymentPassRequest) -> Void) {
// Data -> String
func stringfy(_ data: Data) -> String {
return data.base64EncodedString()
}
BankApplePayAPI.fetchPaymentData(cardNo: cardNo,
certificates: certificates.map { stringfy($0) },
nonce: stringfy(nonce),
nonceSignature: stringfy(nonceSignature)) { result, error in
guard let params = result else {
XCGLogger.default.info("接口返回有誤乳乌,請檢查:\(error ?? "")")
return
}
guard let data = params["encryptedPassData"] as? String,
let key = params["ephemeralPublicKey"] as? String,
let otp = params["activationData"] as? String else {
XCGLogger.default.info("接口返回參數(shù)有誤"); return
}
let encryptedPassData = Data(base64Encoded: data)
let ephemeralPublicKey = Data(base64Encoded: key)
let activationData = Data(base64Encoded: otp)
let request = PKAddPaymentPassRequest()
request.activationData = activationData
request.encryptedPassData = encryptedPassData
request.ephemeralPublicKey = ephemeralPublicKey
handler(request)
}
}
/// 加載完成結果
/// - Parameters:
/// - controller: VC
/// - pass: 申請得到的pass
/// - error: 失敗參數(shù)
public func addPaymentPassViewController(_ controller: PKAddPaymentPassViewController, didFinishAdding pass: PKPaymentPass?, error: Error?) {
XCGLogger.default.info("\(error?.localizedDescription)")
controller.dismiss(animated: true, completion: nil)
if let c = self.callback {
if pass != nil {
c(true, nil)
} else if error != nil {
c(false, NSError.init(domain: error!.domain, code: error!.code, userInfo: nil))
}
}
}
}
public class BankApplePayAPI: NSObject {
/// 查詢支付Configuration
/// @param cardNum 卡號
public static func fetchPaymentConfiguration(cardNo: String,
callback: @escaping (_ result: [String: Any]?, _ error: String?) -> Void) {
var bag: DisposeBag? = DisposeBag()
APIFetch.fetch(host: host,
path: path,
parameters: ["cardNumber": cardNo],
options: nil,
method: Post,
disposeBag: bag!)
.subscribe(onNext: { json in
guard let dict = json as? [String: Any] else {
callback(nil, "返回字段非字典類型捧韵,請檢查"); return
}
callback(dict["value"] as? [String: Any], nil)
}, onError: { error in
callback(nil, error.localizedDescription)
}) {
bag = nil; print("清理")
}.disposed(by: bag!)
}
/// 查詢支付數(shù)據(jù)
/// @param cardNumber 卡號
/// @param certificates 證書文件的base64字符串 0 葉子證書 1 中級證書 2 root證書 (這個沒有可不傳入)
/// @param nonce 隨機數(shù)
/// @param nonceSignature 加密后隨機數(shù)
/// /mb/nmm33g/debit-card/apple/encrypt
public static func fetchPaymentData(cardNo: String,
certificates: [String],
nonce: String,
nonceSignature: String,
callback: @escaping (_ result: [String: Any]?, _ error: String?) -> Void) {
var bag: DisposeBag? = DisposeBag()
BankFetch.fetch(host: host,
path: path,
parameters: [
"cardNumber": cardNo,
"certificates": certificates,
"nonce": nonce,
"nonceSignature": nonceSignature,
],
options: nil,
method: Post,
disposeBag: bag!)
.subscribe(onNext: { json in
guard let dict = json as? [String: Any] else {
callback(nil, "返回字段非字典類型,請檢查"); return
}
callback(dict["value"] as? [String: Any], nil)
}, onError: { error in
callback(nil, error.localizedDescription)
}) {
bag = nil; print("清理")
}.disposed(by: bag!)
}
}
如何測試汉操?
測試必須是production環(huán)境再来。
蘋果有文檔指出可以以下3種方式:
1. Sandbox
2. TestFlight
但蘋果一直強調(diào),sandbox不穩(wěn)定,推薦TestFlight芒篷。Visa方不支持sandbox搜变,只有線上環(huán)境。所以我們選擇直接在TestFlight測試针炉。
3. AppStore
蘋果推薦挠他,在TestFlight通過后,上線時在AppStore驗證篡帕。
注意
- TestFlight測試绩社,ios最低版本必須選擇>=10.3,否則調(diào)不起in-app流程赂苗。
- 出現(xiàn)綁卡失敗問題,需要提供機器的SEID給蘋果贮尉,蘋果可以協(xié)助查找原因拌滋。
遇到的問題點
1. PKAddPaymentPassViewController返回nil,無法調(diào)起in-app流程
- 首先猜谚,蘋果要給開通In-app權限败砂,需要在develop.apple.com中,編輯并勾選權限關聯(lián)到證書中魏铅。
- 需要在Xcode中配置entitlements文件昌犹,添加com.apple.developer.payment-pass-provisioning為YES
- TestFlight測試,ios最低版本必須選擇>=10.3览芳,否則調(diào)不起來斜姥。
2. 調(diào)起in-app后,綁卡失敗
這個問題點就多了沧竟,大多失敗在Visa及FD铸敏,我簡單總結幾點
- App是否開了代理。蘋果能檢測到抓包悟泵,直接報網(wǎng)絡失敗杈笔。我調(diào)試時是先切抓包,map顯示卡tab糕非,然后切非抓包網(wǎng)絡蒙具,點擊進入in-app流程。
- 白名單(卡ID + SEID)
- 銀行后臺準備JSON字段錯誤
- 銀行后臺加密存在錯誤(蘋果加密一層朽肥,Visa加密一層禁筏。Bank傳給蘋果,蘋果解密后發(fā)給Visa鞠呈,Visa解密后拿到初始JSON)(1. ephemeralPublicKey 65bytes 2. ephemeralPublicKey需要轉為Hex)
- 需要在TestFlight測試融师,并且testFlight要求iOS >= 10.3(很奇怪,上線卻只要求>=9.0)
3. 綁卡后蚁吝,PKPassLibrary().passes()找不到對應卡片
檢查VCMM(Visa提供的錄入用戶數(shù)據(jù)的平臺)上associatedApplicationIdentifiers字段旱爆,他是由兩段組成“teamID.bundleId”舀射,需要填入App對應的數(shù)據(jù)。如果填錯怀伦,PKPassLibrary內(nèi)方法將返回不準確脆烟,及拿不到passes。
4. 綁卡后房待,PKPassLibrary().canAddPaymentPass仍返回true
同上
5. 無法智能提示iPhone或iWatch去綁卡
當設備同時有iPhone及iWatch時邢羔,如果我的iPhone已經(jīng)添加綁定,此時再次點擊“Add To Apple Wallet”按鈕桑孩,希望直接走iWatch的綁定(而不是出現(xiàn)選擇界面)拜鹤。
檢查PKAddPaymentPassRequestConfiguration的primaryAccountIdentifier配置,是否有傳入DPAN值流椒,它就是蘋果用來篩選設備的敏簿。我們就是因為后臺返回了空導致filter無效。
6. Flutter中宣虾,“Add To Apple Wallet”的橋接UI覆蓋住了Alert
Flutter的繪制原理惯裕,就是在bitmap上繪制合成完成,才進行渲染绣硝。對于原生來說蜻势,無論你Flutter在哪個頁面,頁面包含多少元素鹉胖,在原生調(diào)試就是薄薄的一層界面握玛。
而我們的首頁Alert也是Flutter實現(xiàn)的,所以原生按鈕無法被夾心次员,浮在了Alert的上方败许,導致?lián)踝lert。
解決辦法是:
在初始化FlutterPlatformView時,按鈕應在init中實例化,在獲取View時直接return此實例悯衬。Flutter會自動判斷被原生組件蓋住的部分叮称,然后再原生層上層再繪制被覆蓋的區(qū)域,看上去仿佛原生被夾心。
required init(frame: CGRect, viewID: Int64, args: Any?, binaryMessenger: FlutterBinaryMessenger) {
button = PKAddPassButton(addPassButtonStyle: .blackOutline)
}
func view() -> UIView {
return button
}
難點
1. 調(diào)試
相比其它需求開發(fā),調(diào)試相對是困難的。
- ---音羞。
- release環(huán)境,無法抓包仓犬,無法調(diào)試嗅绰,很多次都通過上TestFlight,Toast報調(diào)試信息。
- 測試必須發(fā)到TestFlight窘面,我使用Adhoc證書試翠语,都不可以。
- 想要有卡table入口财边,必須加白名單肌括。
- 無測試環(huán)境,只能發(fā)布生產(chǎn)驗證bug酣难,及bug修改后是否修復谍夭。
2. 英文溝通
無論是蘋果還是FD,對話都是英語憨募。
蘋果給出的官方文檔紧索,是全部英文的; 挺多次的視頻通話是全英文,很考驗聽力; 出問題時菜谣,咨詢蘋果也要英文對話齐板,全靠大能的谷歌翻譯。