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ビットのみ なので、公式が示すように、試用済みかどうか、支払い済みかどうか、取引拒否者かどうかなどの用途に限られ、かつ一つの用途のみ実現可能です。
対応バージョン: 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」→ 右上の「+」を選択して新規作成

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

Step 2. 「Confirm」確認

完了。
最後のステップが完了したら、Key IDをメモし、「Download」をクリックして privateKey.p8 秘密鍵ファイルをダウンロードしてください。
この時点で、プッシュ通知に必要なすべてのデータが揃っています:
-
チームID
-
キーID
-
privateKey.p8
3. Appleの規定に従って JWT(JSON Web Token) フォーマットを組み立てる
アルゴリズム: ES256
//HEADER:
{
"alg": "ES256",
"kid": キーID
}
//PAYLOAD:
{
"iss": チームID,
"iat": リクエストタイムスタンプ(Unixタイムスタンプ、例:1556549164),
"exp": 有効期限タイムスタンプ(Unixタイムスタンプ、例:1557000000)
}
//タイムスタンプは必ず整数形式で!
組み合わせたJWT文字列を取得:xxxxxx.xxxxxx.xxxxxx
4. データをAppleサーバーに送信&返却結果を取得する
APNSプッシュ通知と同様に開発環境と本番環境があります:
- 開発環境:api.development.devicecheck.apple.com (なぜか開発環境で送信するといつも失敗が返ってきます)
- 本番環境: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ビットデータの1番目の値: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サーバーに問い合わせて結果を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 {
// エラー処理
}
//!!!!!!以上はテスト・デモ用であり、本番環境での使用は推奨しません!!!!!!
//
}
}
override func viewDidLoad() {
super.viewDidLoad()
DCDevice.current.generateToken { dataOrNil, errorOrNil in
guard let data = dataOrNil else { return }
let deviceToken = data.base64EncodedString()
//正式な場合:
//deviceTokenをバックエンドにPOSTし、バックエンドがAppleサーバーに問い合わせて結果を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 {
// エラー処理
}
//!!!!!!以上はテスト・デモ用であり、本番環境での使用は推奨しません!!!!!!
//
}
// 追加のセットアップをここに記述
}
}

画面スクリーンショット
ここでは一度限りの特典取得を行っており、各デバイスは一回だけ受け取れます!
完全なプロジェクトダウンロード:
Post は Medium から ZMediumToMarkdown によって変換されました。



コメント