ZhgChg.Li

iOS DeviceCheck 實作一次性優惠與試用|Swift 教學完整流程解析

針對 iOS 開發者解決裝置唯一識別難題,透過 DeviceCheck API 實現防止多次試用與優惠濫用,教你 Swift 端取得 Token、後端組合 JWT 串接 Apple 伺服器,保障一次性優惠功能穩定執行。

iOS DeviceCheck 實作一次性優惠與試用|Swift 教學完整流程解析

iOS 完美實踐一次性優惠或試用的方法 (Swift)

iOS DeviceCheck 跟著你到天涯海角

在寫上一篇 Call Directory Extension 時無意間發現這個冷門的API,雖然已不是什麼新鮮事(WWDC 2017時公布/iOS ≥11支援)、實作方面也非常簡易;但還是小小的研究測試了一下並整理出文章當做個紀錄.

DeviceCheck 能幹嘛?

允許開發者針對使用者的裝置進行識別標記

自從 iOS ≥ 6 之後開發者無法取得使用者裝置的唯一識別符(UUID),折衷的做法是使用IDFV結合KeyChain(詳細可參考之前 這篇 ),但在 iCloud 換帳號或是重置手機…等狀況下,UUID還是會重置;無法保證裝置的唯一性,如果以此作為一些業務邏輯的儲存及判斷,例如:首次免費試用,就可能發生使用者狂換帳號、重置手機,可不斷無限試用的漏洞.

DeviceCheck 雖然不能讓我們得到保證不會改變的UUID,但他能做到「 儲存」 的功能,每個裝置Apple提供2 bits的雲端儲存空間,透過傳送裝置產生的臨時識別Token給Apple,可寫入/讀取那2 bits的資訊。

2 bits? 能存什麼?

只能組合出4種狀態,能做的功能有限.

與原本儲存方式比較:

✓ 表示資料還在

✓ 表示資料還在

p.s. 這邊小弟犧牲了自已的手機實際做了測試,結果吻合;就算我登出換iCloud、清出所有資料、還原所有設定、回到原廠初始狀態,重新安裝完APP都還是能取到值.

主要運作流程如下:

iOS APP 這邊透過DeviceCheck API產生一組識別裝置用的臨時Token,傳給後端再經由後端組合開發者的private key資訊、開發者資訊成JWT格式後轉傳給Apple伺服器;後端取得Apple回傳結果後處理完格式再丟回iOS APP.

DeviceCheck 的應用

附上 DeviceCheck 在 WWDC2017 上的截圖:

每個裝置只能存2 bits的資訊 ,所以能做的項目差不多就如官方所提及的應用包含裝置是否曾經已試用過、是否付費過、是否是拒絕往來戶…等等;且只能實現一項.

支援度: iOS ≥ 11

開始!

了解完基本資訊後,讓我們開始動手做吧!

iOS APP 端:

import DeviceCheck
//....
//
DCDevice.current.generateToken { dataOrNil, errorOrNil in
  guard let data = dataOrNil else { return }
  let deviceToken = data.base64EncodedString()
            
   //...
   //POST deviceToken 到後端,請後端去跟蘋果伺服器查詢,然後再回傳結果給APP處理
}

如流程所述,APP要做的只有取得臨時識別Token( deviceToken )!

再來就是將deviceToken發送到後端我們自己的API去處理.

後端:

重點在後端處理的部分

1.首先登入 開發者後台 記下 Team ID

2. 再點側欄的 Certificates, IDs & Profiles 前往憑證管理平台

選擇「Keys」-> 「All」-> 右上角「+」新增

選擇「Keys」-> 「All」-> 右上角「+」新增

Step 1.建立新Key,勾選「DeviceCheck」

Step 1.建立新Key,勾選「DeviceCheck」

Step 2. 「Confirm」確認

Step 2. 「Confirm」確認

Finished.

Finished.

最後一步建立完成後, 記下 Key ID 及點擊「Download」下載回 privateKey.p8 私鑰檔案.

這時候你已經準備齊全了所有推播所需資料:

  1. Team ID
  2. Key ID
  3. privateKey.p8

3. 依Apple規範組合 JWT(JSON Web Token) 格式

演算法: ES256

//HEADER:
{
  "alg": "ES256",
  "kid": Key ID
}
//PAYLOAD:
{
  "iss": Team ID,
  "iat": 請求時間戳(Unix Timestamp,EX:1556549164),
  "exp": 逾期時間戳(Unix Timestamp,EX:1557000000)
}
//時間戳務必是整數格式!

取得組合的JWT字串:xxxxxx.xxxxxx.xxxxxx

4. 將資料發送給Apple伺服器&取得回傳結果

同APNS推播有分開發環境跟正式環境: 1.開發環境:api.development.devicecheck.apple.com (不知道為什麼我開發環境發送都會回傳失敗) 2.正式環境:api.devicecheck.apple.com

DeviceCheck API 提供兩個操作: 1.查詢儲存資料: https://api.devicecheck.apple.com/v1/query_two_bits

//Headers:
Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (組合的JWT字串)

//Content:
device_token:deviceToken (要查詢的裝置Token)
transaction_id:UUID().uuidString (查詢識別符,這裡直接用UUID代表)
timestamp: 請求時間戳(毫秒),注意!這裡是毫秒(EX: 1556549164000)

回傳狀態:

官方文件

官方文件

回傳內容:

{
  "bit0": Int:2 bits 資料中第一位的資料:01,
  "bit1": Int:2 bits 資料中第二位的資料:01,
  "last_update_time": String:"最後修改時間 YYYY-MM"
}

p.s. 你沒看錯,最後修改時間就只能顯示到年-月

2.寫入儲存資料: https://api.devicecheck.apple.com/v1/update_two_bits

//Headers:
Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (組合的JWT字串)

//Content:
device_token:deviceToken (要查詢的裝置Token)
transaction_id:UUID().uuidString (查詢識別符,這裡直接用UUID代表)
timestamp: 請求時間戳(毫秒),注意!這裡是毫秒(EX: 1556549164000)
bit0: 2 bits 資料中第一位的資料:0或1
bit1: 2 bits 資料中第二位的資料:0或1

5. 取得Apple伺服器回傳結果

回傳狀態:

官方文件

官方文件

回傳內容:無,回傳狀態 200 即表示寫入成功!

6. 後端API回傳結果給APP

APP在針對相應的狀態做回應就完成了!

後端部分補充:

這邊太久沒碰PHP了,有興趣請參考 iOS11で追加されたDeviceCheckについて 這篇文章的 requestToken.php 部分

Swift 版示範Demo:

因後端部分我無法提供實作且不是大家都會PHP,這邊提供一個用純iOS (Swift) 做的範例,直接在APP裡處理後端該做的那些事(組JWT,發送資料給頻果),給大家做參考!

不需撰寫後端程式就能模擬執行所有內容.

⚠請注意 僅為測試示範所需,不建議用於正式環境

這邊要感謝 Ethan Huang 大大的 CupertinoJWT 提供 iOS 在APP內產生JWT格式內容的支援!

Demo 主要程式及畫面:

import UIKit
import DeviceCheck
import CupertinoJWT

extension String {
    var queryEncode:String {
        return self.addingPercentEncoding(withAllowedCharacters: .whitespacesAndNewlines)?.replacingOccurrences(of: "+", with: "%2B") ?? ""
    }
}
class ViewController: UIViewController {

    
    @IBOutlet weak var getBtn: UIButton!
    @IBOutlet weak var statusBtn: UIButton!
    @IBAction func getBtnClick(_ sender: Any) {
        DCDevice.current.generateToken { dataOrNil, errorOrNil in
            guard let data = dataOrNil else { return }
            
            let deviceToken = data.base64EncodedString()
            
            //正式情況:
            //POST deviceToken 到後端,請後端去跟蘋果伺服器查詢,然後再回傳結果給APP處理
            
            
            //!!!!!!以下僅為測試、示範所需,不建議用於正式環境!!!!!!
            //!!!!!!      請勿隨意暴露您的PRIVATE KEY    !!!!!!
                let p8 = """
                    -----BEGIN PRIVATE KEY-----
                    -----END PRIVATE KEY-----
                    """
                let keyID = "" //你的KEY ID
                let teamID = "" //你的Developer Team ID :https://developer.apple.com/account/#/membership
            
                let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
            
                do {
                    let token = try jwt.sign(with: p8)
                    var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/update_two_bits")!)
                    request.httpMethod = "POST"
                    request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
                    request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
                    let json:[String : Any] = ["device_token":deviceToken,"transaction_id":UUID().uuidString,"timestamp":Int(Date().timeIntervalSince1970.rounded()) * 1000,"bit0":true,"bit1":false]
                    request.httpBody = try? JSONSerialization.data(withJSONObject: json)
                    
                    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
                        guard let data = data else {
                            return
                        }
                        print(String(data:data, encoding: String.Encoding.utf8))
                        DispatchQueue.main.async {
                            self.getBtn.isHidden = true
                            self.statusBtn.isSelected = true
                        }
                    }
                    task.resume()
                } catch {
                    // Handle error
                }
            //!!!!!!以上僅為測試、示範所需,不建議用於正式環境!!!!!!
            //
            
        }

    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        DCDevice.current.generateToken { dataOrNil, errorOrNil in
            guard let data = dataOrNil else { return }
            
            let deviceToken = data.base64EncodedString()
            
            //正式情況:
                //POST deviceToken 到後端,請後端去跟蘋果伺服器查詢,然後再回傳結果給APP處理
            
            
            //!!!!!!以下僅為測試、示範所需,不建議用於正式環境!!!!!!
            //!!!!!!      請勿隨意暴露您的PRIVATE KEY    !!!!!!
                let p8 = """
                -----BEGIN PRIVATE KEY-----
                
                -----END PRIVATE KEY-----
                """
                let keyID = "" //你的KEY ID
                let teamID = "" //你的Developer Team ID :https://developer.apple.com/account/#/membership
            
                let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
            
                do {
                    let token = try jwt.sign(with: p8)
                    var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/query_two_bits")!)
                    request.httpMethod = "POST"
                    request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
                    request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
                    let json:[String : Any] = ["device_token":deviceToken,"transaction_id":UUID().uuidString,"timestamp":Int(Date().timeIntervalSince1970.rounded()) * 1000]
                    request.httpBody = try? JSONSerialization.data(withJSONObject: json)
                    
                    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
                        guard let data = data,let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String:Any],let stauts = json["bit0"] as? Int else {
                            return
                        }
                        print(json)
                        
                        if stauts == 1 {
                            DispatchQueue.main.async {
                                self.getBtn.isHidden = true
                                self.statusBtn.isSelected = true
                            }
                        }
                    }
                    task.resume()
                } catch {
                    // Handle error
                }
            //!!!!!!以上僅為測試、示範所需,不建議用於正式環境!!!!!!
            //
            
        }
        // Do any additional setup after loading the view.
    }


}

畫面截圖

畫面截圖

這邊做的是一個一次性的優惠領取,每個裝置只能領一次!

完整專案下載:

在 GitHub 上補充修正
編輯這篇文章
本文首次發表於 Medium
點此查看原文
分享這篇文章
複製連結 · 分享到社群
ZhgChgLi
作者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

留言 · Comments