在 Android 上自訂 Firebase 電話號碼驗證流程

「開始使用 Firebase Phone Number Verification頁面詳細說明如何使用 getVerifiedPhoneNumber() 方法整合 Firebase PNV,這個方法會處理整個 Firebase PNV 流程,包括取得使用者同意聲明,以及對 Firebase PNV 後端進行必要的網路呼叫。

建議大多數開發人員使用單一方法 API (getVerifiedPhoneNumber())。不過,如果您需要更精細地控制與 Android Credential Manager 的互動 (例如要求其他憑證和電話號碼),Firebase PNV 程式庫也提供下列兩種方法,每種方法都會處理與 Firebase PNV 後端的不同互動:

  • getDigitalCredentialPayload() 會取得伺服器簽署的要求,您將使用這項要求叫用憑證管理工具。
  • exchangeCredentialResponseForPhoneNumber() 會將憑證管理工具的回應換成包含已驗證電話號碼的簽署權杖。

在呼叫每個方法之間,您必須負責處理與 Android 認證管理員 API 的互動。本頁將概述如何實作這三部分流程。

事前準備

設定 Firebase 專案,並按照「開始使用」頁面的說明匯入 Firebase PNV 依附元件。

1. 取得數位憑證要求酬載

呼叫 getDigitalCredentialPayload() 方法,產生裝置電話號碼的要求。在下一個步驟中,這項要求會成為您與 Credential Manager API 互動時的酬載。

// This instance does not require an Activity context.
val fpnv = FirebasePhoneNumberVerification.getInstance()

// Your request should include a nonce, which will propagate through the flow
// and be present in the final response from FPNV. See the section "Verifying
// the Firebase PNV token" for details on generating and verifying this.
val nonce = fetchNonceFromYourServer()

fpnv.getDigitalCredentialPayload(nonce, "https://example.com/privacy-policy")
  .addOnSuccessListener { fpnvDigitalCredentialPayload ->
    // Use the payload in the next step.
    // ...
  }
  .addOnFailureListener { e -> /* Handle payload fetch failure */ }

2. 使用 Credential Manager 提出數位憑證要求

接著,將要求傳遞給 Credential Manager。

如要這麼做,您必須將要求酬載包裝在 DigitalCredential API 要求中。這項要求必須包含您傳遞至 getDigitalCredentialPayload() 的相同隨機碼。

// This example uses string interpolation for clarity, but you should use some kind of type-safe
// serialization method.
fun buildDigitalCredentialRequestJson(nonce: String, fpnvDigitalCredentialPayload: String) = """
    {
      "requests": [
        {
          "protocol": "openid4vp-v1-unsigned",
          "data": {
            "response_type": "vp_token",
            "response_mode": "dc_api",
            "nonce": "$nonce",
            "dcql_query": { "credentials": [$fpnvDigitalCredentialPayload] }
          }
        }
      ]
    }
""".trimIndent()

完成上述步驟後,您可以使用 Credential Manager API 發出要求:

suspend fun makeFpnvRequest(
  context: Activity, nonce: String, fpnvDigitalCredentialPayload: String): GetCredentialResponse {
  // Helper function to build the digital credential request (defined above).
  // Pass the same nonce you passed to getDigitalCredentialPayload().
  val digitalCredentialRequestJson =
    buildDigitalCredentialRequestJson(nonce, fpnvDigitalCredentialPayload)

  // CredentialManager requires an Activity context.
  val credentialManager = CredentialManager.create(context)

  // Build a Credential Manager request that includes the Firebase PNV option. Note that
  // you can't combine the digital credential option with other options.
  val request = GetCredentialRequest.Builder()
    .addCredentialOption(GetDigitalCredentialOption(digitalCredentialRequestJson))
    .build()

  // getCredential is a suspend function, so it must run in a coroutine scope,
  val cmResponse: GetCredentialResponse = try {
    credentialManager.getCredential(context, request)
  } catch (e: GetCredentialException) {
    // If the user cancels the operation, the feature isn't available, or the
    // SIM doesn't support the feature, a GetCredentialCancellationException
    // will be returned. Otherwise, a GetCredentialUnsupportedException will
    // be returned with details in the exception message.
    throw e
  }
  return cmResponse
}

如果憑證管理工具呼叫成功,回應會包含數位憑證,您可以使用下列範例程式碼擷取:

val dcApiResponse = extractApiResponse(cmResponse)
fun extractApiResponse(response: GetCredentialResponse): String {
  val credential = response.credential
  when (credential) {
    is DigitalCredential -> {
      val json = JSONObject(credential.credentialJson)
      val firebaseJwtArray =
          json.getJSONObject("data").getJSONObject("vp_token").getJSONArray("firebase")
      return firebaseJwtArray.getString(0)

    }
    else -> {
      // Handle any unrecognized credential type here.
      Log.e(TAG, "Unexpected type of credential ${credential.type}")
    }
  }
}

3. 將數位憑證回應換成 Firebase PNV 權杖

最後,呼叫 exchangeCredentialResponseForPhoneNumber() 方法,將數位憑證回應換成已驗證的電話號碼和 Firebase PNV 權杖:

fpnv.exchangeCredentialResponseForPhoneNumber(dcApiResponse)
  .addOnSuccessListener { result ->
    val phoneNumber = result.getPhoneNumber()
    // Verification successful
  }
  .addOnFailureListener { e -> /* Handle exchange failure */ }

4. 驗證 Firebase PNV 權杖

如果流程成功,getVerifiedPhoneNumber() 方法會傳回已驗證的電話號碼,以及包含該號碼的簽署權杖。您可以在應用程式中使用這項資料,但必須遵守隱私權政策的規定。

如果您在應用程式用戶端以外使用已驗證的電話號碼,請傳遞權杖,而非電話號碼本身,以便在使用時驗證完整性。如要驗證權杖,您需要實作兩個端點:

  • Nonce 生成端點
  • 權杖驗證端點

這些端點的實作方式由您決定,以下範例說明如何使用 Node.js 和 Express 實作這些端點。

產生 Nonce

這個端點負責產生並暫時儲存一次性值 (稱為隨機數),用於防範針對端點的重送攻擊。舉例來說,您可能會定義類似這樣的 Express 路由:

app.get('/fpnvNonce', async (req, res) => {
    const nonce = crypto.randomUUID();

    // TODO: Save the nonce to a database, key store, etc.
    // You should also assign the nonce an expiration time and periodically
    // clear expired nonces from your database.
    await persistNonce({
        nonce,
        expiresAt: Date.now() + 180000, // Give it a short duration.
    });

    // Return the nonce to the caller.
    res.send({ nonce });
});

這是步驟 1 中預留位置函式 fetchNonceFromYourServer() 會呼叫的端點。隨機碼會透過用戶端執行的各種網路呼叫傳播,最終在 Firebase PNV 權杖中傳回伺服器。在下一個步驟中,您會驗證權杖是否包含您產生的隨機值。

驗證權杖

這個端點會接收來自用戶端的 Firebase PNV 權杖,並驗證權杖的真實性。如要驗證權杖,請檢查:

  • 權杖是使用 Firebase PNV JWKS 端點發布的金鑰簽署:

    https://fpnv.googleapis.com/v1beta/jwks
    
  • 對象和簽發者聲明包含 Firebase 專案編號,格式如下:

    https://fpnv.googleapis.com/projects/FIREBASE_PROJECT_NUMBER
    

    您可以在 Firebase 主控台的「專案設定」頁面中找到 Firebase 專案編號。

  • 權杖尚未過期。

  • 權杖包含有效的隨機數。如果符合下列條件,隨機碼即為有效:

    • 您已產生該 ID (也就是說,您使用的任何持續性機制中都有該 ID)
    • 尚未用過
    • 尚未過期

舉例來說,Express 實作方式可能如下所示:

import { JwtVerifier } from "aws-jwt-verify";

// Find your Firebase project number in the Firebase console.
const FIREBASE_PROJECT_NUMBER = "123456789";

// The issuer and audience claims of the FPNV token are specific to your
// project.
const issuer = `https://fpnv.googleapis.com/projects/${FIREBASE_PROJECT_NUMBER}`;
const audience = `https://fpnv.googleapis.com/projects/${FIREBASE_PROJECT_NUMBER}`;

// The JWKS URL contains the current public signing keys for FPNV tokens.
const jwksUri = "https://fpnv.googleapis.com/v1beta/jwks";

// Configure a JWT verifier to check the following:
// - The token is signed by Google
// - The issuer and audience claims match your project
// - The token has not yet expired (default begavior)
const fpnvVerifier = JwtVerifier.create({ issuer, audience, jwksUri });

app.post('/verifiedPhoneNumber', async (req, res) => {
    if (!req.body) return res.sendStatus(400);
    // Get the token from the body of the request.
    const fpnvToken = req.body;
    try {
        // Attempt to verify the token using the verifier configured above.
        const verifiedPayload = await fpnvVerifier.verify(fpnvToken);

        // Now that you've verified the signature and claims, verify the nonce.
        // TODO: Try to look up the nonce in your database and remove it if it's
        // found; if it's not found or it's expired, throw an error.
        await testAndRemoveNonce(verifiedPayload.nonce);

        // Only after verifying the JWT signature, claims, and nonce, get the
        // verified phone number from the subject claim.
        // You can use this value however it's needed by your app.
        const verifiedPhoneNumber = verifiedPayload.sub;
        // (Do something with it...)

        return res.sendStatus(200);
    } catch {
        // If verification fails, reject the token.
        return res.sendStatus(400);
    }
});