使用 Firebase App Check 和 reCAPTCHA 驗證 Places API 要求

1. 事前準備

為確保使用者與網頁應用程式互動的合法性,您將導入 Firebase App Check,並利用 reCAPTCHA JWT 權杖驗證使用者工作階段。這項設定可讓您安全地處理從用戶端應用程式傳送至 Places API (新版) 的要求。

b40cfddb731786fa.png

即時連結

建構目標

為了說明這項功能,您將建立一個網頁應用程式,在載入時顯示地圖。並會使用 Firebase SDK 悄悄產生 reCAPTCHA 權杖。接著,這個權杖會傳送至 Node.js 伺服器,Firebase 會在驗證權杖後,再滿足對 Places API 的任何要求。

如果權杖有效,Firebase App Check 會儲存權杖,直到權杖到期為止,因此您不必為每個用戶端要求建立新的權杖。如果權杖無效,系統會提示使用者再次完成 reCAPTCHA 驗證,以取得新的權杖。

2. 先決條件

您必須熟悉下列項目,才能完成本程式碼研究室。daea823b6bc38b67.png

必要的 Google Cloud 產品

  • Google Cloud Firebase App Check:用於管理權杖的資料庫
  • Google reCAPTCHA:建立和驗證權杖。這項工具可用於區分網站上的真人和機器人。這項功能會分析使用者行為、瀏覽器屬性和網路資訊,產生分數,指出使用者是機器人的可能性。如果分數夠高,系統就會判定使用者是真人,因此不需要採取進一步行動。如果分數偏低,系統可能會顯示驗證圖片,要求使用者確認身分。這種做法比傳統的 CAPTCHA 方法干擾程度較低,可提供更流暢的使用者體驗。
  • (選用) Google Cloud App Engine:部署環境。

必要的 Google 地圖平台產品

在本程式碼研究室中,您將使用下列 Google 地圖平台產品:

本程式碼研究室的其他規定

如要完成本程式碼研究室,您需要具備下列帳戶、服務和工具:

  • 已啟用帳單功能的 Google Cloud Platform 帳戶
  • 已啟用 Maps JavaScript API 和 Places 的 Google 地圖平台 API 金鑰
  • 具備 JavaScript、HTML 和 CSS 的基本知識
  • 具備 Node.js 的基本知識
  • 您選擇的文字編輯器或 IDE

3. 進行設定

設定 Google 地圖平台

如果您尚未建立 Google Cloud Platform 帳戶,以及已啟用帳單功能的專案,請參閱「開始使用 Google 地圖平台」指南,建立帳單帳戶和專案。

  1. Cloud 控制台中,點選專案下拉式選單,然後選取要用於本程式碼研究室的專案。

e7ffad81d93745cd.png

  1. Google Cloud Marketplace 中啟用本程式碼研究室所需的 Google 地圖平台 API 和 SDK。如要這樣做,請按照這部影片這份說明文件中的步驟操作。
  2. 在 Cloud 控制台的「憑證」頁面中產生 API 金鑰。您可以按照這部影片這份說明文件中的步驟操作。所有 Google 地圖平台要求都需要 API 金鑰。

應用程式預設憑證

您將使用 Firebase Admin SDK 與 Firebase 專案互動,並向 Places API 提出要求,因此必須提供有效的憑證,才能讓 SDK 正常運作。

我們會使用 ADC 驗證 (自動預設憑證) 驗證您的伺服器,以便提出要求。或者,您也可以建立服務帳戶,並在程式碼中儲存憑證 (不建議)。

定義:應用程式預設憑證 (ADC) 是 Google Cloud 提供的一種機制,可自動驗證應用程式,而無需明確管理憑證。會在各種位置 (例如環境變數、服務帳戶檔案或 Google Cloud 中繼資料伺服器) 中尋找憑證,並使用找到的第一個憑證。

  • 在終端機中使用下列指令,讓應用程式可代表目前登入的使用者,安全地存取 Google Cloud 資源:
gcloud auth application-default login
  • 您會在根目錄中建立 .env 檔案,指定 Google Cloud 專案變數:
GOOGLE_CLOUD_PROJECT="your-project-id"

建立服務帳戶

憑證

  • 按一下已建立的服務帳戶
  • 前往「金鑰」分頁,然後依序點選「建立金鑰」>「JSON」> 儲存下載的 JSON 憑證。將自動下載的 xxx.json 檔案移至根目錄
  • (下一章) 在 nodejs 檔案 server.js (firebase-credentials.json) 中正確命名

4. Firebase AppCheck 整合

您將取得 Firebase 設定詳細資料和 reCAPTCHA 密鑰。

您會將這些值貼入示範應用程式,並啟動伺服器。

在 Firebase 中建立應用程式

SELECT 已建立的 Google Cloud 專案 (您可能需要指定「選取父項資源」)

a6d171c6d7e98087.png a16010ba102cc90b.png

  • 從左上方的選單 (齒輪圖示) 新增應用程式

18e5a7993ad9ea53.png 4632158304652118.png

Firebase 初始化程式碼

  • 儲存 Firebase 初始化程式碼,以便將其貼到用戶端的 script.js (下一章)

f10dcf6f5027e9f0.png

  • 註冊應用程式,讓 Firebase 使用 reCAPTCHA v3 權杖

https://console.firebase.google.com/u/0/project/YOUR_PROJECT/appcheck/apps

da7efe203ce4142c.png

  • 選擇 reCAPTCHA → 在 reCAPTCHA 網站中建立金鑰 (設定正確的網域:應用程式開發人員的 localhost)

b47eab131617467.png e6bddef9d5cf5460.png

  • 在 Firebase AppCheck 中貼上 reCAPTCHA 密鑰

a63bbd533a1b5437.png

  • 應用程式狀態應變成綠色

4f7962b527b78ee5.png

5. 試用版應用程式

  • 用戶端網頁應用程式:HTML、JavaScript、CSS 檔案
  • 伺服器:Node.js 檔案
  • 環境 (.env):API 金鑰
  • 設定 (app.yaml):Google App Engine 部署設定

Node.js 設定:

  • 導覽:開啟終端機,然後前往複製專案的根目錄。
  • 安裝 Node.js (如有需要):18 以上版本。
node -v  # Check installed version
  • 初始化專案:執行下列指令,初始化新的 Node.js 專案,並保留所有預設設定:
npm init 
  • 安裝依附元件:使用下列指令安裝必要的專案依附元件:
npm install @googlemaps/places firebase-admin express axios dotenv

設定:Google Cloud 專案的環境變數

  • 建立環境檔案:在專案的根目錄中建立名為 .env 的檔案。這個檔案會儲存機密設定資料,因此不應提交至版本控制系統。
  • 填入環境變數:開啟 .env 檔案,然後加入下列變數,並將預留位置替換為 Google Cloud 專案中的實際值:
# Google Cloud Project ID
GOOGLE_CLOUD_PROJECT="your-cloud-project-id"

# reCAPTCHA Keys (obtained in previous steps) 
RECAPTCHA_SITE_KEY="your-recaptcha-site-key"
RECAPTCHA_SECRET_KEY="your-recaptcha-secret-key"

# Maps Platform API Keys (obtained in previous steps)
PLACES_API_KEY="your-places-api-key"
MAPS_API_KEY="your-maps-api-key"

6. 程式碼總覽

index.html

  • 載入 Firebase 程式庫,在應用程式中建立權杖
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Places API with AppCheck</title>
  <style></style>  </head>
<body>
  <div id="map"></div>

    <!-- Firebase services -->
  <script src="https://www.gstatic.com/firebasejs/9.15.0/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.15.0/firebase-app-check-compat.js"></script>
  
  <script type="module" src="./script.js"></script> 
  <link rel="stylesheet" href="./style.css">
</body>
</html>

script.js

  • 擷取 API 金鑰:從後端伺服器擷取 Google 地圖和 Firebase App Check 的 API 金鑰。
  • 初始化 Firebase:設定 Firebase 以進行驗證和安全防護。(取代設定 → 請參閱第 4 章)。

Firebase App Check 權杖的有效期限長度介於 30 分鐘到 7 天之間,可在 Firebase 控制台中設定,無法透過強制權杖重新整理來變更。

  • 啟用 App Check:啟用 Firebase App Check,以便驗證傳入要求的真實性。
  • 載入 Google 地圖 API:動態載入 Google 地圖 JavaScript 程式庫,以便顯示地圖。
  • 初始化地圖:建立以預設位置為中心的 Google 地圖。
  • 處理地圖點擊:監聽地圖上的點擊,並據此更新中心點。
  • 查詢 Places API:向後端 API (/api/data) 傳送要求,使用 Firebase App Check 授權,擷取點選位置附近的餐廳、公園、酒吧等地點資訊。
  • 顯示標記:將擷取的資料以標記的形式在地圖上顯示,並顯示名稱和圖示。
let mapsApiKey, recaptchaKey; // API keys
let currentAppCheckToken = null; // AppCheck token

async function init() {
  try {
    await fetchConfig(); // Load API keys from .env variable

    /////////// REPLACE with your Firebase configuration details
    const firebaseConfig = {
      apiKey: "AIza.......",
      authDomain: "places.......",
      projectId: "places.......",
      storageBucket: "places.......",
      messagingSenderId: "17.......",
      appId: "1:175.......",
      measurementId: "G-CPQ.......",
    };
    /////////// REPLACE 

    // Initialize Firebase and App Check
    await firebase.initializeApp(firebaseConfig);
    await firebase.appCheck().activate(recaptchaKey);

    // Get the initial App Check token
    currentAppCheckToken = await firebase.appCheck().getToken();

    // Load the Maps JavaScript API dynamically
    const scriptMaps = document.createElement("script");
    scriptMaps.src = `https://maps.googleapis.com/maps/api/js?key=${mapsApiKey}&libraries=marker,places&v=beta`;
    scriptMaps.async = true;
    scriptMaps.defer = true;
    scriptMaps.onload = initMap; // Create the map after the script loads
    document.head.appendChild(scriptMaps);
  } catch (error) {
    console.error("Firebase initialization error:", error);
    // Handle the error appropriately (e.g., display an error message)
  }
}
window.onload = init()

// Fetch configuration data from the backend API
async function fetchConfig() {
  const url = "/api/config";

  try {
    const response = await fetch(url);
    const config = await response.json();
    mapsApiKey = config.mapsApiKey;
    recaptchaKey = config.recaptchaKey;
  } catch (error) {
    console.error("Error fetching configuration:", error);
    // Handle the error (e.g., show a user-friendly message)
  }
}

// Initialize the map when the Maps API script loads
let map; // Dynamic Map
let center = { lat: 48.85557501, lng: 2.34565006 };
function initMap() {
  map = new google.maps.Map(document.getElementById("map"), {
    center: center,
    zoom: 13,
    mapId: "b93f5cef6674c1ff",
    zoomControlOptions: {
      position: google.maps.ControlPosition.RIGHT_TOP,
    },
    streetViewControl: false,
    mapTypeControl: false,
    clickableIcons: false,
    fullscreenControlOptions: {
      position: google.maps.ControlPosition.LEFT_TOP,
    },
  });

  // Initialize the info window for markers
  infoWindow = new google.maps.InfoWindow({});

  // Add a click listener to the map
  map.addListener("click", async (event) => {
    try {
      // Get a fresh App Check token on each click
      const appCheckToken = await firebase.appCheck().getToken();
      currentAppCheckToken = appCheckToken;

      // Update the center for the Places API query
      center.lat = event.latLng.lat();
      center.lng = event.latLng.lng();

      // Query for places with the new token and center
      queryPlaces();
    } catch (error) {
      console.error("Error getting App Check token:", error);
    }
  });
}

function queryPlaces() {
  const url = '/api/data'; // "http://localhost:3000/api/data"

  const body = {
    request: {
      includedTypes: ['restaurant', 'park', 'bar'],
      excludedTypes: [],
      maxResultCount: 20,
      locationRestriction: {
        circle: {
          center: {
            latitude: center.lat,
            longitude: center.lng,
          },
          radius: 4000,
        },
      },
    },
  };

  // Provides token to the backend using header: X-Firebase-AppCheck

  fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Firebase-AppCheck': currentAppCheckToken.token,
    },
    body: JSON.stringify(body),
  })
    .then((response) => response.json())
    .then((data) => {
      // display if response successful
      displayMarkers(data.places);
    })
    .catch((error) => {
      alert('No places');
      // eslint-disable-next-line no-console
      console.error('Error:', error);
    });
}


//// display places markers on map
...

server.js

  • .env 檔案載入環境變數 (API 金鑰、Google 專案 ID)。
  • 啟動伺服器,監聽 http://localhost:3000 上的要求。
  • 使用應用程式預設憑證 (ADC) 初始化 Firebase Admin SDK
  • script.js 接收 reCAPTCHA 權杖
  • 驗證收到的權杖是否有效
  • 如果符記有效,就會向 Google Places API 發出 POST 要求,並附上搜尋參數。
  • 將 Places API 的回應處理並傳回給用戶端。
const express = require('express');
const axios = require('axios');

const admin = require('firebase-admin');

// .env variables
require('dotenv').config();

// Store sensitive API keys in environment variables
const recaptchaSite = process.env.RECAPTCHA_SITE_KEY;
const recaptchaSecret = process.env.RECAPTCHA_SECRET_KEY;
const placesApiKey = process.env.PLACES_API_KEY;
const mapsApiKey = process.env.MAPS_API_KEY;

// Verify environment variables loaded (only during development)
console.log('recaptchaSite:', recaptchaSite, '\n');
console.log('recaptchaSecret:', recaptchaSecret, '\n');

const app = express();
app.use(express.json());

// Firebase Admin SDK setup with Application Default Credentials (ADC)
const { GoogleAuth } = require('google-auth-library');
admin.initializeApp({
  // credential: admin.credential.applicationDefault(), // optional: explicit ADC
});

// Main API Endpoint 
app.post('/api/data', async (req, res) => {
  const appCheckToken = req.headers['x-firebase-appcheck'];

  console.log("\n", "Token", "\n", "\n", appCheckToken, "\n")

  try {
    // Verify Firebase App Check token for security
    const appCheckResult = await admin.appCheck().verifyToken(appCheckToken);

    if (appCheckResult.appId) {
      console.log('App Check verification successful!');
      placesQuery(req, res);
    } else {
      console.error('App Check verification failed.');
      res.status(403).json({ error: 'App Check verification failed.' });
    }
  } catch (error) {
    console.error('Error verifying App Check token:', error);
    res.status(500).json({ error: 'Error verifying App Check token.' });
  }
});

// Function to query Google Places API
async function placesQuery(req, res) {
  console.log('#################################');
  console.log('\n', 'placesApiKey:', placesApiKey, '\n');

  const queryObject = req.body.request;
  console.log('\n','Request','\n','\n', queryObject, '\n')

  const headers = {
    'Content-Type': 'application/json',
    'X-Goog-FieldMask': '*',
    'X-Goog-Api-Key': placesApiKey,
    'Referer': 'http://localhost:3000',  // Update for production(ie.: req.hostname)
  };

  const myUrl = 'https://places.googleapis.com/v1/places:searchNearby';

  try {
    // Authenticate with ADC
    const auth = new GoogleAuth();
    const { credential } = await auth.getApplicationDefault();

    const response = await axios.post(myUrl, queryObject, { headers, auth: credential });
    
    console.log('############### SUCCESS','\n','\n','Response','\n','\n', );
    const myBody = response.data;
    myBody.places.forEach(place => {
      console.log(place.displayName); 
    });
    res.json(myBody); // Use res.json for JSON data
  } catch (error) {
    console.log('############### ERROR');
    // console.error(error); // Log the detailed error for debugging
    res.status(error.response.status).json(error.response.data); // Use res.json for errors too
  }
}

// Configuration endpoint (send safe config data to the client)
app.get('/api/config', (req, res) => {
  res.json({
    mapsApiKey: process.env.MAPS_API_KEY, 
    recaptchaKey: process.env.RECAPTCHA_SITE_KEY, 
  });
});

// Serve static files
app.use(express.static('static'));

// Start the server
const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Server listening on port ${port}`, '\n');
});

7. 執行應用程式

在所選環境中,透過終端機執行伺服器,然後前往 http://localhost:3000

npm start 

系統會建立權杖做為全域變數,隱藏在使用者的瀏覽器視窗中,並傳送至伺服器進行處理。如需權杖的詳細資料,請參閱伺服器記錄。

如要進一步瞭解伺服器的函式,以及對 Places API Nearby Search 要求的回應,請參閱伺服器記錄。

疑難排解:

請確認 Google 專案 ID 在設定中保持一致:

  • 在 .env 檔案中 (GOOGLE_CLOUD_PROJECT 變數)
  • 在終端機 gcloud 設定中:
gcloud config set project your-project-id
  • 在 reCAPTCHA 設定中

e6bddef9d5cf5460.png

  • 在 Firebase 設定中

7e17bfbcb8007763.png

其他

  • 建立偵錯符記,以便在 script.js 中取代 reCAPTCHA 網站金鑰,用於測試和疑難排解。

9c0beb760d13faef.png

try {
 // Initialize Firebase first
 await firebase.initializeApp(firebaseConfig);
  // Set the debug token
  if (window.location.hostname === 'localhost') { // Only in development
    await firebase.appCheck().activate(
      'YOUR_DEBUG_FIREBASE_TOKEN', // Replace with the token from the console
      true // Set to true to indicate it's a debug token
      );
  } else {
      // Activate App Check
      await firebase.appCheck().activate(recaptchaKey);
}
  • 如果嘗試驗證次數過多 (例如使用錯誤的 reCAPTCHA 網站金鑰),系統可能會暫時限制頻率。
FirebaseError: AppCheck: Requests throttled due to 403 error. Attempts allowed again after 01d:00m:00s (appCheck/throttled).

ADC 憑證

  • 確認您登入的是正確的 gcloud 帳戶
gcloud auth login 
  • 確認已安裝必要的程式庫
npm install @googlemaps/places firebase-admin
  • 確認 server.js 已載入 Firebase 程式庫
const {GoogleAuth} = require('google-auth-library');
gcloud auth application-default login
  • 冒用:已儲存 ADC 憑證
gcloud auth application-default login --impersonate-service-account your_project@appspot.gserviceaccount.com
  • 最後,請在本機測試 ADC,將下列指令碼儲存為 test.js,然後在終端機中執行:node test.js
const {GoogleAuth} = require('google-auth-library');

async function requestTestADC() {
 try {
   // Authenticate using Application Default Credentials (ADC)
   const auth = new GoogleAuth();
   const {credential} = await auth.getApplicationDefault();

   // Check if the credential is successfully obtained
   if (credential) {
     console.log('Application Default Credentials (ADC) loaded successfully!');
     console.log('Credential:', credential); // Log the credential object
   } else {
     console.error('Error: Could not load Application Default Credentials (ADC).');
   }

   // ... rest of your code ...

 } catch (error) {
   console.error('Error:', error);
 }
}

requestTestADC();

8. 就是這樣,做得好!

後續步驟

部署至 App Engine:

  • 準備好專案,以便部署至 Google App Engine,並進行必要的設定變更。
  • 請使用 gcloud 指令列工具或 App Engine 主控台部署應用程式。

強化 Firebase 驗證:

  • 預設權杖與自訂權杖:實作 Firebase 自訂權杖,進一步使用 Firebase 服務。
  • 權杖生命週期:設定適當的權杖生命週期,敏感作業的生命週期較短 (自訂 Firebase 權杖最多為 1 小時),一般工作階段的生命週期較長 (reCAPTCHA 權杖:30 分鐘至 7 小時)。
  • 探索 reCAPTCHA 的替代方案:調查 DeviceCheck (iOS)、SafetyNet (Android) 或 App Attest 是否符合您的安全需求。

整合 Firebase 產品:

  • 即時資料庫或 Firestore:如果應用程式需要即時資料同步處理或離線功能,請整合即時資料庫或 Firestore。
  • Cloud Storage:使用 Cloud Storage 儲存及提供使用者原創內容,例如圖片或影片。
  • 驗證:利用 Firebase 驗證功能建立使用者帳戶、管理登入工作階段,以及處理密碼重設作業。

擴展至行動裝置:

  • Android 和 iOS:如果您打算推出行動應用程式,請為 Android 和 iOS 平台建立版本。
  • Firebase SDK:使用 Android 和 iOS 版 Firebase SDK,將 Firebase 功能無縫整合至行動應用程式。