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

「開始使用 Firebase Phone Number Verification」頁面詳細說明如何使用 getVerifiedPhoneNumber() 方法與 Firebase PNV 整合,該方法會處理整個 Firebase PNV 流程,從取得使用者同意聲明,到向 Firebase PNV 後端發出必要的網路呼叫。

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

  • getDigitalCredentialPayload() 會取得伺服器簽署的要求,您將使用該要求叫用 Credential Manager。
  • exchangeCredentialResponseForPhoneNumber() 會將 Credential Manager 的回應,換成包含已驗證電話號碼的已簽署權杖。系統會在這個步驟收費。

在呼叫這些方法之間,您必須負責處理與 Android Credential Manager API 的互動。本頁面概略說明如何實作這個三部分流程。

事前準備

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

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

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

Kotlin

// 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() 的相同 Nonce。

Kotlin

// 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 提出要求:

Kotlin

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
}

如果 Credential Manager 呼叫成功,回應會包含數位憑證,您可以使用類似以下範例的程式碼擷取:

Kotlin

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 權杖:

Kotlin

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

這個端點負責產生及暫時儲存稱為 Nonce 的單次使用值,用於防止端點遭受重送攻擊。舉例來說,您可以定義如下的 Express 路由:

Node.js

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() 會呼叫的端點。Nonce 會透過用戶端執行的各種網路呼叫傳播,最終在 Firebase PNV 權杖中傳回伺服器。在下一個步驟中,您將驗證權杖是否包含您產生的 Nonce。

驗證權杖

這個端點會從用戶端接收 Firebase PNV 權杖,並驗證其真實性。如要驗證權杖,請檢查下列項目:

  • typ 標頭設為 JWT。

  • 權杖會使用 Firebase PNV JWKS 端點發布的其中一個金鑰簽署,並採用 ES256 演算法:

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

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

    您可以在 Firebase 控制台的 「Settings」(設定) > 「General」(一般) 分頁中找到 Firebase 專案編號。

  • 目標對象宣告是一份清單,包含 Firebase 專案編號和專案 ID,格式如下:

    [
      https://fpnv.googleapis.com/projects/FIREBASE_PROJECT_NUMBER,
      https://fpnv.googleapis.com/projects/FIREBASE_PROJECT_ID,
    ]
    
  • 權杖未過期。

  • 權杖包含有效的 Nonce。符合下列條件的 Nonce 才有效:

    • 您已產生該 ID (也就是說,您可以在使用的任何持續性機制中找到該 ID)
    • 未經使用
    • 尚未過期

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

Node.js

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);
    }
});