iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

一、前言

昨天鏢局正式開張,App 也順利呼叫到雲端的函式。
不過開張第一天,也發現了一件讓人不太放心的事:
任何人只要知道網址,都能直接呼叫這支函式,不一定是自家的 App。

  • 怎麼確認上門的,真的是自家的 App?
  • 呼叫 Gemini 這類付費服務的金鑰,要放在哪裡?

今天就替鏢局增添 通行守衛(App Check)和 金鑰保險箱(Secret Manager)!


二、守衛:App Check

(一)App Check 是什麼

App Check 會替每一個請求附上一張「通行證」(App Check 權杖),
證明請求來自正版的 App,而且跑在真的裝置上。

通行證由誰來開,依平台不同:

環境 驗證方式 說明
Android 實機 Play Integrity 由 Google Play 服務確認 App 與裝置
iPhone 實機 App Attest 由 Apple 確認 App 與裝置
模擬器 debug provider 模擬器不是真的裝置,改用開發用的偵錯權杖

函式開啟檢查之後,沒有通行證、或通行證無效的請求,一律擋下。

(二)在主控台註冊 App 的驗證方式

昨天執行 flutterfire configure 時,
App 已經註冊到 Firebase,這裡只要替兩個平台各選一種驗證方式:

1. iOS:App Attest

① 選擇 App Attest

② 輸入團隊 ID

團隊 ID 可以在 Apple Developer 網站「Membership details」查到:

小提醒:權杖存留時間
團隊 ID 下面的「權杖存留時間」,是通行證的有效期限,
預設 1 小時;Android 的註冊畫面也有這一欄。

不用擔心一小時後 App 就不能用:SDK 大約在有效期限過一半時,
就會自動換一張新的通行證。

設短一點比較安全,但驗證會更頻繁、也更快用掉配額;
官方認為預設的 1 小時適合大多數 App(官方說明),維持預設就好。

2. Android:Play Integrity

① 選擇 Play Integrity

② 輸入 SHA-256 憑證指紋

Android App 安裝前都要先用金鑰簽章。
簽章就像替 App 蓋章,證明「這是我做的、沒被改過」。

SHA-256 指紋就像這把金鑰的身分證字號,
Firebase 靠它確認發出請求的是你簽章的 App。

終端機查詢 SHA-256 ,輸入指令:

keytool -list -v -keystore ~/.android/debug.keystore \
  -alias androiddebugkey -storepass android -keypass android

debug 和 prod 所用的金鑰簽章是不一樣的,指紋也不同。

3. Play Integrity 的進階設定

展開「進階設定」會看到三個選項:

  • 前兩個看「App 是不是從 Google Play 來的」
  • 第三個看「手機本身可不可信」
選項 檢查什麼 預設
必須有應用程式完整性標籤「PLAY_RECOGNIZED」 App 和簽章憑證,跟 Google Play 發布的版本一致 要求
必須具備帳戶詳細資料標籤「LICENSED」 使用者是從 Google Play 安裝或更新這個 App 不要求
可接受的最低裝置完整性等級 手機至少要達到哪個等級才放行 不要確切檢查

第三個選項「最低裝置完整性等級」,由低到高有四種:

等級 意思 需要留意
不要確切檢查裝置完整性等級 不指定等級 Play Integrity 仍可能自己做額外的檢查,這個設定關不掉
完整性:基本 通過基本的系統檢查;bootloader 解鎖、沒經過認證的裝置也可能拿到 要先在 Google Play Console 開啟;Android 13 以上、不是從 Google Play 安裝的使用者拿不到
完整性:裝置等級 真的、經過認證的 Android 裝置 所有 App 都拿得到,不用另外開啟
完整性:高 經過認證,而且有近期的安全性更新(Android 13 以上要一年內) 要先在 Google Play Console 開啟;官方建議只在「只在 Google Play 發布」時考慮

「基本」聽起來最寬鬆,但對沒上架的 App 並不會比較寬鬆:
Android 13 以上的手機拿不到這個標籤,還是要達到「裝置等級」才會放行。

依 App 發布管道,官方建議設定:

App 的發布管道 PLAY_RECOGNIZED LICENSED 最低裝置完整性等級
只在 Google Play 要求 要求 不要確切檢查
只在 Google Play 以外 不要求 不要求 完整性:裝置等級
兩邊都有 要求 不要求 不要確切檢查

要特別留意第一個選項:

PLAY_RECOGNIZED 預設是勾選的,
但沒有在 Google Play 發布的 App 拿不到這個標籤(官方說明)。
還沒上架、自己用 flutter run 裝到手機的版本,照預設設定會被擋下。

這次的示範 App 就是這種情況,所以照「只在 Google Play 以外」設定:

  • PLAY_RECOGNIZED:取消勾選
  • LICENSED:維持不勾
  • 最低裝置完整性等級:選「完整性:裝置等級」

(三)App 端啟用 App Check

① 安裝套件:

flutter pub add firebase_app_check

② 在 Firebase 初始化之後、呼叫函式之前啟用 App Check:

// 模擬器用 debug provider,token 在執行時帶入(見下一節)
const appCheckDebugToken =
    String.fromEnvironment('APP_CHECK_DEBUG_TOKEN');
const useAppCheckDebug = appCheckDebugToken != '';

// App Check 要在呼叫其他 Firebase 服務之前啟用
await FirebaseAppCheck.instance.activate(
  providerAndroid: useAppCheckDebug
      ? const AndroidDebugProvider(debugToken: appCheckDebugToken)
      : const AndroidPlayIntegrityProvider(),
  providerApple: useAppCheckDebug
      ? const AppleDebugProvider(debugToken: appCheckDebugToken)
      : const AppleAppAttestProvider(),
);

以上兩個平台都一樣;接下來只有 iOS 要多做兩步。
Android 不用另外設定,Flutter 套件已經帶入 Play Integrity。

③ iOS:在 Xcode 開啟 App Attest
Runner 的「Signing & Capabilities」→「+ Capability」→ App Attest

④ iOS:把 Runner.entitlements 裡的 App Attest Environment 改成 production

App Check 目前不接受 App Attest 測試環境(sandbox)產生的權杖,
所以就算是開發中的版本,環境也要設成 production(官方說明)。

(四)用模擬器開發時:改用偵錯權杖

模擬器拿不到 Play Integrity、App Attest 的驗證,要改用「偵錯權杖」:
在主控台產生一組值,App 帶著同一組值,就能換到通行證。

① 在主控台產生偵錯權杖:

主控台畫面的 「偵錯符記」,指的就是 「偵錯權杖」。

② 把這組值存成 app_check_debug.json,並加進 .gitignore:

{
  "APP_CHECK_DEBUG_TOKEN": "主控台產生的那組值"
}

③ 在模擬器執行時帶入:

flutter run --dart-define-from-file=app_check_debug.json

小提醒:
iOS 和 Android 在主控台是兩個 App,偵錯權杖要各自登記;
兩種模擬器都要用的話,另一個 App 也要新增同一組值。

偵錯權杖是機密,拿到它就能冒充你的 App,
不要放進版本控制,也不要放進正式版。

(五)先觀察,再開啟強制檢查

App 端加上 App Check 之後,函式的 log 會記錄每個請求的檢查結果。
還沒開啟強制檢查時,通行證無效的請求照樣放行,log 會寫:

Allowing request with invalid AppCheck token
because enforcement is disabled

可以先從 log 確認自家 App 的請求都是 "app":"VALID",再開啟強制檢查:

exports.greet = onCall(
    // 沒有有效 App Check 權杖的請求,一律拒絕
    {enforceAppCheck: true},
    (request) => {
      // ……跟昨天一樣
    },
);
firebase deploy --only functions:greet

(六)確認結果

呼叫方式 結果
curl,沒有通行證 401 UNAUTHENTICATED
curl,帶一張假的通行證 401 UNAUTHENTICATED
Android 模擬器(debug provider) 成功
Android 平板(Play Integrity) 成功
iPhone(App Attest) 成功

直接用 curl 呼叫會被擋下,只有自家的 App 叫得動這支函式。

小提醒:
測試用的 Android 手機如果沒有通過 Google 認證,Play Integrity 會驗證失敗;
可以在 Play 商店 →「設定」→「關於」→「Play 安全防護認證」確認。

我手邊有一支「裝置未通過認證」的手機,實測真的過不了XD
把裝置完整性放寬到不檢查也一樣過不了;換成另一台平板就正常。


三、保險箱:Secret Manager

(一)哪些金鑰要放進保險箱

金鑰 是不是機密 放在哪裡
Firebase API key 不是(Day13 提過) App 裡的 firebase_options.dart
Gemini API 金鑰 是,別人拿去用,帳單得照付 保險箱 Secret Manager

Gemini 的金鑰不能打包進 App,App 裡的東西都可能被取出;
也不適合直接寫在函式的程式碼裡,程式碼會跟著進版本控制。

比較好的做法:
放進 Google Cloud 的保險箱 Secret Manager,函式執行時才取用。

呼叫 Google 自家的服務(例如 Cloud STT)時,
函式可以用自己的身分(服務帳戶)呼叫,連金鑰都不需要。
下一篇實測 Cloud STT 時會用到。

(二)設定步驟:以 Gemini API 金鑰為例

步驟 1:取得 Gemini API 金鑰

金鑰在 Google AI Studio 建立。
用的是免費層還是付費層,看的是金鑰所在的專案:
專案沒有連結帳單帳戶,Billing Tier 顯示「Free tier」,就是免費層。

今天用免費層實作:

① 在「API Keys」頁面,確認專案的 Billing Tier 顯示「Free tier」

② 點選這個專案的金鑰

列表裡沒有金鑰的話,按右上角的「Create API key」,選這個專案建立一把。

③ 在「API key details」按「Copy key」,複製金鑰

免費層有一條要特別留意的條款(Gemini API Additional Terms of Service):
Do not submit sensitive, confidential, or personal information to the Unpaid Services.
不要把敏感、機密或個人資訊送進免費服務。

所以這次只送不含個資的測試文字。
要處理個人資料,得改用付費層;付費層要先預付,Billing 最少 US$5。

步驟 2:存進 Secret Manager

在函式專案的資料夾,終端機輸入指令,照提示貼上金鑰:

firebase functions:secrets:set GEMINI_API_KEY

貼上後,Secret Manager 會存成第 1 版:

✔ Enter a value for GEMINI_API_KEY:
✔  Created a new secret version
   projects/494602719837/secrets/GEMINI_API_KEY/versions/1

金鑰只在終端機輸入,不會出現在程式碼裡;之後換金鑰,會存成第 2 版。

步驟 3:函式宣告要用這把金鑰

先安裝 Gemini 的 SDK:

cd functions
npm install @google/genai

再在 functions/index.js 加上 askGemini,
onCall、HttpsError、logger 沿用昨天引入的:

const {defineSecret} = require("firebase-functions/params");
const {GoogleGenAI} = require("@google/genai");

// 金鑰存在 Secret Manager,程式碼裡只有它的名稱
const geminiApiKey = defineSecret("GEMINI_API_KEY");

exports.askGemini = onCall(
    // secrets:宣告這支函式要用保險箱裡的哪些金鑰,執行時才讀得到
    {secrets: [geminiApiKey], enforceAppCheck: true},
    async (request) => {
      const question = request.data?.question;
      if (typeof question !== "string" || question.length === 0 ||
          question.length > 200) {
        throw new HttpsError(
            "invalid-argument", "問題要 1~200 字");
      }
      const ai = new GoogleGenAI({apiKey: geminiApiKey.value()});
      let response;
      try {
        response = await ai.models.generateContent({
          model: "gemini-3.5-flash-lite",
          contents: question,
        });
      } catch (error) {
        // 原因記在 log(例如 503 模型忙碌),App 收到明確的錯誤代碼
        logger.error("Gemini 呼叫失敗",
            {status: error.status, reason: error.message});
        throw new HttpsError(
            "unavailable", "Gemini 暫時無法回應,請稍後再試");
      }
      return {answer: response.text};
    },
);

幾個重點寫法:

寫法 說明
defineSecret 宣告金鑰在保險箱裡的名稱,要跟步驟 2 存的一樣
secrets: [geminiApiKey] 只有列在這裡的函式,執行時才讀得到這把金鑰
geminiApiKey.value() 在函式執行時取出金鑰
try/catch Gemini 出錯時,App 收到 unavailable,而不是看不出原因的 INTERNAL

官方建議的模型:
「For any new projects, use our latest models: 3.5 Flash-Lite or 3.8 Flash.」
這次用輕量的 gemini-3.5-flash-lite。

步驟 4:部署

firebase deploy --only functions:askGemini

部署時,CLI 會自動讓函式有權限讀取這把金鑰:

✔  secretmanager: Granted roles/secretmanager.secretAccessor
   on projects/kenkou-tw-dev/secrets/GEMINI_API_KEY
   to 494602719837-compute@developer.gserviceaccount.com

(三)確認結果

App 端的呼叫方式跟昨天的 greet 一樣,
把函式名稱換成 askGemini、資料換成 question 就好。

情況 結果
正常呼叫 成功,Gemini 的回答在回應的 result.answer
問題超過 200 字 400 INVALID_ARGUMENT:問題要 1~200 字
沒有通行證,直接用 curl 呼叫 401 UNAUTHENTICATED

Gemini 的回答:

台灣夜市是融合在地美食、庶民文化與人情味的霓虹不夜城,
也是體驗台灣最接地氣的生活百寶箱。

程式碼和 App 裡,都只看得到 GEMINI_API_KEY 這個名稱,看不到金鑰本身;
沒有通行證的請求也進不來:守衛和保險箱都有在運作。

補充:
實測時遇過一次 Gemini 回傳 503:
This model is currently experiencing high demand.
Spikes in demand are usually temporary. Please try again later.
模型目前需求量大,通常是暫時的,請稍後再試。

還沒加 try/catch 之前,App 只收到 INTERNAL;
加上之後,App 會收到 unavailable 和說明,原因在 log 裡。


四、小結

今天替鏢局補上了兩道防護:

  • 守衛(App Check):
    Android 用 Play Integrity、iOS 用 App Attest、模擬器用偵錯權杖;
    沒上架的 App 要取消 PLAY_RECOGNIZED,App Attest 環境要設 production;
    先從 log 觀察,再開啟 enforceAppCheck。
  • 保險箱(Secret Manager):
    金鑰用 firebase functions:secrets:set 存進雲端,
    函式用 defineSecret 和 secrets 宣告後才讀得到;
    程式碼和 App 裡都沒有金鑰。
  • Gemini:
    免費層跟著專案走,不要送個人資訊;
    呼叫外部服務要處理錯誤,App 才知道發生什麼事。

有了守衛和保險箱,鏢局才算真正可以放心營業!


五、預告

Cloud Functions for Firebase 系列,這邊告一段落~
希望我們都對於 Firebase Functions 有多一些認識!

鏢局開好了,我們就可以放心拿令牌請俠客 Google Cloud Speech-to-Text 來幫忙:
聽聽看,是否能辨識 短音節音訊「怕、踏、卡、啦」和 繞口令 !

明天實測 Cloud Speech-to-Text,替健口動一動的語音辨識找解方!

感謝有緣看到這邊的你~
希望佛菩薩也祝福你:🌟平安歡喜 自在順心🌟
南無觀世音菩薩🍀 南無地藏菩薩🏠 南無阿彌陀佛☀️


上一篇
Cloud Functions for Firebase(中)
下一篇
Flutter : Google Cloud Speech to Text (上)
系列文
Build on Google AI :長者照護 —— 口腔機能訓練 與 延緩認知退化 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言