ZhgChg.Li

iOS DeviceCheckで実現する一次性割引・試用の完璧な管理|Swift活用法

iOSユーザーの重複利用を防ぎたい開発者向けに、DeviceCheckを使った一次性割引や試用の確実な制御方法を解説。Swift実装で安全かつ効率的にユーザー体験を向上させます。

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ビットのクラウドストレージを提供しており、デバイスが生成した一時的な識別トークンをAppleに送信することで、その2ビットの情報を読み書きできます。

2ビット?何が保存できるの?

4つの状態しか組み合わせられないため、できる機能は限られています。

元の保存方法との比較:

✓ はデータがまだ存在することを示します

✓ はデータがまだ存在していることを示します

p.s. ここで私自身のスマホを使って実際にテストを行い、結果は一致しました。iCloudからサインアウトしても、すべてのデータを消去し、すべての設定をリセットし、工場出荷時の状態に戻してからアプリを再インストールしても、値を取得できました。

主な動作フローは以下の通りです:

iOSアプリはDeviceCheck APIを使ってデバイス識別用の一時トークンを生成し、それをバックエンドに送信します。バックエンドは開発者のプライベートキー情報や開発者情報を組み合わせてJWT形式に変換し、Appleサーバーへ送信します。バックエンドはAppleからの返答を受け取り、フォーマット処理を行った後、iOSアプリに返します。

DeviceCheck の応用

DeviceCheck の WWDC2017 でのスクリーンショットを添付:

各デバイスには2ビットの情報しか保存できないため、公式で示されているような「デバイスが既に試用済みか」「支払い済みか」「ブラックリストか」などの用途に限られ、かつ1項目のみ実装可能です。

対応バージョン: iOS 11以上

はじめに!

基本情報を理解したら、さっそく始めましょう!

iOS APP 側:

import DeviceCheck
//....
//
DCDevice.current.generateToken { dataOrNil, errorOrNil in
  guard let data = dataOrNil else { return }
  let deviceToken = data.base64EncodedString()
            
   //...
   //deviceTokenをバックエンドにPOSTし、バックエンドでAppleサーバーに問い合わせてから結果をAPPに返す
}

フローの通り、APPが行うのは一時識別トークン(deviceToken)を取得するだけです!

次に、deviceTokenを自分たちのバックエンドAPIに送信して処理します。

バックエンド:

重點はバックエンド処理の部分にあります

1.まずは 開発者アカウント にログインし、Team IDを控える

2. サイドバーの Certificates, IDs & Profiles をクリックして証明書管理ページへ移動する

「Keys」を選択 -> 「All」-> 右上の「+」で新規追加

「Keys」-> 「All」-> 右上の「+」を選択して追加

Step 1.新しいキーを作成し、「DeviceCheck」を選択

Step 1. 新しいキーを作成し、「DeviceCheck」にチェックを入れる

Step 2. 「確認」

Step 2. 「Confirm」(確認)

Finished.

完了。

最後の手順が完了したら、Key ID を控え、「Download」をクリックして privateKey.p8 の秘密鍵ファイルをダウンロードしてください。

この時点で、プッシュ通知に必要なすべてのデータが揃っています:

  1. チーム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タイムスタンプ、例:1556549164,
  "exp": 有効期限のタイムスタンプ(Unixタイムスタンプ、例:1557000000
}
//タイムスタンプは必ず整数形式で!

組み合わせたJWT文字列を取得:xxxxxx.xxxxxx.xxxxxx

4. Appleサーバーにデータを送信&返却結果を取得する

APNSプッシュ通知と同様に開発環境と本番環境が分かれています:

  1. 開発環境:api.development.devicecheck.apple.com (なぜか開発環境で送信するといつも失敗が返ってきます)
  2. 本番環境:api.devicecheck.apple.com

DeviceCheck API は2つの操作を提供します:
1. 保存データの照会: https://api.devicecheck.apple.com/v1/query_two_bits

//Headers:
Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (組み立てたJWT文字列)

//Content:
device_token:deviceToken (確認するデバイストークン)
transaction_id:UUID().uuidString (問い合わせ識別子、ここではUUIDを使用)
timestamp: リクエストのタイムスタンプ(ミリ秒)、注意!ここはミリ秒です(例: 1556549164000)

返却ステータス:

公式ドキュメント

公式ドキュメント

返却内容:

{
  "bit0": Int:2ビットデータの最初のビットの値:0または1,
  "bit1": Int:2ビットデータの2番目のビットの値:0または1,
  "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 (照会するデバイストークン)
transaction_id:UUID().uuidString (照会識別子、ここではUUIDを使用)
timestamp: リクエストのタイムスタンプ(ミリ秒)、注意!ここはミリ秒です(例: 1556549164000)
bit0: 2ビットデータの1番目の値:0または1
bit1: 2ビットデータの2番目の値:0または1

5. Appleサーバーからの返却結果を取得する

返却ステータス:

公式ドキュメント

公式ドキュメント

返却内容:なし、ステータス200が返されれば書き込み成功を意味します!

6. バックエンドAPIがAPPに結果を返す

APPは対応する状態に応じて処理を行うだけで完了です!

バックエンド部分の補足:

こちらはPHPにしばらく触っていなかったので、ご興味があればiOS11で追加されたDeviceCheckについてのrequestToken.php部分をご参照ください。

Swift バージョンのデモ例:

後ろの部分は実装できず、皆さんがPHPを使えるわけではないので、ここでは純粋なiOS(Swift)での例を提供します。APP内でバックエンドが行うべき処理(JWTの生成やAppleへのデータ送信)を直接行う方法の参考にしてください!

バックエンドのプログラムを書かなくても、すべての内容をシミュレーション実行できます。

⚠ご注意 テストデモ用のみであり、本番環境での使用は推奨しません

こちらでは Ethan Huang さんの素晴らしい CupertinoJWT に感謝します。iOSアプリ内で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()
            
            //正式な場合:
            //deviceTokenをバックエンドにPOSTし、バックエンドがAppleサーバーに問い合わせて結果をアプリに返す
            
            
            //!!!!!!以下はテスト・デモ用のみ、正式環境での使用は推奨しません!!!!!!
            //!!!!!!      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 {
                    // エラー処理
                }
            //!!!!!!以上はテスト・デモ用のみ、正式環境での使用は推奨しません!!!!!!
            //
            
        }

    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        DCDevice.current.generateToken { dataOrNil, errorOrNil in
            guard let data = dataOrNil else { return }
            
            let deviceToken = data.base64EncodedString()
            
            //正式な場合:
                //deviceTokenをバックエンドにPOSTし、バックエンドがAppleサーバーに問い合わせて結果をアプリに返す
            
            
            //!!!!!!以下はテスト・デモ用のみ、正式環境での使用は推奨しません!!!!!!
            //!!!!!!      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 {
                    // エラー処理
                }
            //!!!!!!以上はテスト・デモ用のみ、正式環境での使用は推奨しません!!!!!!
            //
            
        }
        // 追加のセットアップがあればここに記述
    }


}

画面スクリーンショット

画面のスクリーンショット

ここで行っているのは一度限りの特典受け取りで、各デバイスは一回だけ受け取れます!

完全なプロジェクトダウンロード:

Post MediumからZMediumToMarkdownを使って変換しました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

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

コメント