概要と起きた問題
自作の自動売買ボット(CQAT)などのバックエンドAPIを、Cloudflare Tunnel経由で外部公開し、セキュリティを高めるためにCloudflare Zero Trustで「メール認証(Email OTP)」を設定した。
これによりブラウザからのアクセスは守られるようになったが、iOSアプリ(SwiftUI)からの通信時にログイン画面のHTMLが返されるようになり、JSONのパースエラー(Parse Error)が発生してしまった。
解決策:Service Token(サービストークン)の利用
プログラム(アプリ)からの自動通信を許可するために、Cloudflareで「Service Token」を発行し、アプリからのHTTPリクエストのヘッダーにそのトークンを含めて裏側で自動認証させるように設定する。
手順1:CloudflareでService Tokenを発行する
- Cloudflare Zero Trust ダッシュボードを開く。
- 左メニューから Access > Service Auth へ進み、「Add a service token」(または Create a service token)をクリック。
- 用途がわかる名前(例: CQAT-App)と有効期限(例: 1年)を設定して「Generate」を押す。
- 画面に Client ID と Client Secret が表示される。
⚠️ 注意:
Client Secret はこの画面を閉じると二度と確認できないため、必ずコピーして安全な場所にメモしておくこと。
手順2:アプリケーションにポリシー(許可ルール)を紐付ける
作成した通行証(トークン)を、対象のアプリ(ドメイン)で使えるように設定する。
- 左メニューの Access > Applications を開き、対象のアプリの「Edit」をクリック。
- 上部の Policies タブを開き、「Add a policy」 をクリック。
- 以下の内容でポリシーを追加し、保存する。
- Action:
Service Auth - Rule: Include を選択 > Selectorを
Service Tokenにする > Valueで先ほど作ったトークン名を選択。
- Action:
手順3:iOSアプリ(SwiftUI)側のコード改修
URLを直接指定してリクエストしていた部分を URLRequest に書き換え、ヘッダーに取得したIDとSecretを追加する。
修正前(例):
URLSession.shared.dataTask(with: url) { data, _, error in ... }
修正後:
var request = URLRequest(url: url)
// 取得したトークン情報をヘッダーに追加
request.addValue("取得したClient_ID", forHTTPHeaderField: "CF-Access-Client-Id")
request.addValue("取得したClient_Secret", forHTTPHeaderField: "CF-Access-Client-Secret")
URLSession.shared.dataTask(with: request) { data, _, error in
// 以降の処理
}
これでアプリからの通信がCloudflareの認証を自動でパスし、無事にJSONデータを取得できるようになった。
おまけ:仮想通貨の「資産詳細」タブの追加
メイン画面に加えて、保有している現物資産と現在の建玉(ポジション)の個別状況を確認できるタブを新規作成した。ContentView.swift に以下のビューを追加し、MainTabView の TabView 内に組み込む。
struct AssetsView: View {
@ObservedObject var vm: CQATViewModel
var body: some View {
NavigationView {
List {
// 現物資産の表示
if let assets = vm.data?.assets, !assets.isEmpty {
Section(header: Text("保有資産 (現物)").foregroundColor(.gray)) {
ForEach(assets) { asset in
HStack {
Text(asset.currency).font(.headline).bold()
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text("数量: \(String(format: "%.4f", asset.amount))").font(.subheadline)
Text("現在価格: ¥\(Int(asset.price))").font(.caption).foregroundColor(.gray)
Text("評価額: ¥\(Int(asset.value))").font(.subheadline).bold().foregroundColor(.green)
}
}
.padding(.vertical, 4)
}
}
}
// 建玉(ポジション)の表示
if let positions = vm.data?.positions, !positions.isEmpty {
Section(header: Text("現在の建玉 (ポジション)").foregroundColor(.gray)) {
ForEach(positions) { pos in
HStack {
Text(pos.pair).font(.headline).bold()
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text("数量: \(String(format: "%.4f", pos.amount))").font(.subheadline)
Text("取得価格: ¥\(Int(pos.price))").font(.caption).foregroundColor(.gray)
}
}
.padding(.vertical, 4)
}
}
}
}
.navigationTitle("仮想通貨情報")
.refreshable { vm.fetchData() } // 下に引っ張って更新
}
}
}