ZhgChg.Li

iOS DeviceCheck 实作一次性优惠与试用|Swift 教学完整流程解析

针对 iOS 开发者解决装置唯一识别难题,透过 DeviceCheck API 实现防止多次试用与优惠滥用,教你 Swift 端取得 Token、后端组合 JWT 串接 Apple 伺服器,保障一次性优惠功能稳定执行。

iOS DeviceCheck 实作一次性优惠与试用|Swift 教学完整流程解析
本文使用 AI 翻译,如有不妥敬请告知。"

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