驗證服務對服務

如果您的架構使用多項服務,這些服務可能需要透過非同步或同步方式彼此通訊。其中許多服務可能都是私人服務,因此需要憑證才能存取

如要進行非同步通訊,可以使用下列 Google Cloud 服務:

在上述所有情況下,所用的服務都會根據您設定的設定,管理與接收服務的互動。

但如果是同步通訊,您的服務會使用端點網址,透過 HTTP 直接呼叫其他服務。在這種情況下,您應確保每個服務都只能對特定服務提出要求。舉例來說,如果您有 login 服務,此服務應該能夠存取 user-profiles 服務,但無法存取 search 服務。

在這種情況下,Google 建議您使用 IAM 和服務身分,並以每個服務的使用者代管服務帳戶為基礎,授予最低權限組合,確保服務身分具備執行工作所需的權限。

此外,要求必須提供呼叫服務身分證明。如要這麼做,請設定通話服務,在要求中加入 Google 簽署的 OpenID Connect ID 權杖。

設定服務帳戶

如要設定服務帳戶,請將呼叫服務的服務帳戶設為接收服務的主體,藉此將接收服務設定為接受來自呼叫服務的要求。然後將 Cloud Run 叫用者 (roles/run.invoker) 角色授予該服務帳戶。如要執行這兩項工作,請按照相關分頁的操作說明執行:

主控台使用者介面

  1. 前往 Google Cloud 控制台:

    前往 Google Cloud 控制台

  2. 選取接收的服務。

  3. 按一下右上角的 [Show Info Panel] (顯示資訊面板),顯示「Permissions」(權限) 分頁。

  4. 按一下「新增主體」

    1. 輸入呼叫服務的身分。這通常是電子郵件地址,預設為 PROJECT_NUMBER-compute@developer.gserviceaccount.com

    2. 從「Select a role」(請選擇角色) 下拉式選單中,選取 Cloud Run Invoker 角色。

    3. 按一下 [儲存]

gcloud

使用 gcloud run services add-iam-policy-binding 指令:

gcloud run services add-iam-policy-binding RECEIVING_SERVICE \
  --member='serviceAccount:CALLING_SERVICE_IDENTITY' \
  --role='roles/run.invoker'

其中 RECEIVING_SERVICE 是接收服務的名稱,而 CALLING_SERVICE_IDENTITY 是服務帳戶的電子郵件地址,預設為 PROJECT_NUMBER-compute@developer.gserviceaccount.com

Terraform

如要瞭解如何套用或移除 Terraform 設定,請參閱「基本 Terraform 指令」。

在 Terraform 設定中,將下列項目新增至 google_cloud_run_v2_service 資源:

resource "google_cloud_run_v2_service" "public" {
  name     = "public-service"
  location = "us-central1"

  deletion_protection = false # set to "true" in production

  template {
    containers {
      # TODO<developer>: replace this with a public service container
      # (This service can be invoked by anyone on the internet)
      image = "us-docker.pkg.dev/cloudrun/container/hello"

      # Include a reference to the private Cloud Run
      # service's URL as an environment variable.
      env {
        name  = "URL"
        value = google_cloud_run_v2_service.private.uri
      }
    }
    # Give the "public" Cloud Run service
    # a service account's identity
    service_account = google_service_account.default.email
  }
}

us-docker.pkg.dev/cloudrun/container/hello 替換為容器映像檔的參照。

下列 Terraform 程式碼會將初始服務設為公開。

data "google_iam_policy" "public" {
  binding {
    role = "roles/run.invoker"
    members = [
      "allUsers",
    ]
  }
}

resource "google_cloud_run_service_iam_policy" "public" {
  location = google_cloud_run_v2_service.public.location
  project  = google_cloud_run_v2_service.public.project
  service  = google_cloud_run_v2_service.public.name

  policy_data = data.google_iam_policy.public.policy_data
}

下列 Terraform 程式碼會建立第二個 Cloud Run 服務,該服務預計為私有

resource "google_cloud_run_v2_service" "private" {
  name     = "private-service"
  location = "us-central1"

  deletion_protection = false # set to "true" in production

  template {
    containers {
      // TODO<developer>: replace this with a private service container
      // (This service should only be invocable by the public service)
      image = "us-docker.pkg.dev/cloudrun/container/hello"
    }
  }
}

us-docker.pkg.dev/cloudrun/container/hello 替換為容器映像檔的參照。

下列 Terraform 程式碼會將第二項服務設為私有。

data "google_iam_policy" "private" {
  binding {
    role = "roles/run.invoker"
    members = [
      "serviceAccount:${google_service_account.default.email}",
    ]
  }
}

resource "google_cloud_run_service_iam_policy" "private" {
  location = google_cloud_run_v2_service.private.location
  project  = google_cloud_run_v2_service.private.project
  service  = google_cloud_run_v2_service.private.name

  policy_data = data.google_iam_policy.private.policy_data
}

下列 Terraform 程式碼會建立服務帳戶。

resource "google_service_account" "default" {
  account_id   = "cloud-run-interservice-id"
  description  = "Identity used by a public Cloud Run service to call private Cloud Run services."
  display_name = "cloud-run-interservice-id"
}

下列 Terraform 程式碼可讓附加至服務帳戶的服務,叫用初始私有 Cloud Run 服務。

data "google_iam_policy" "private" {
  binding {
    role = "roles/run.invoker"
    members = [
      "serviceAccount:${google_service_account.default.email}",
    ]
  }
}

resource "google_cloud_run_service_iam_policy" "private" {
  location = google_cloud_run_v2_service.private.location
  project  = google_cloud_run_v2_service.private.project
  service  = google_cloud_run_v2_service.private.name

  policy_data = data.google_iam_policy.private.policy_data
}

取得及設定 ID 權杖

將適當角色授予呼叫服務帳戶後,請按照下列步驟操作:

  1. 使用下一節所述的其中一種方法,擷取 Google 簽署的 ID 權杖。將對象憑證附加資訊 (aud) 設為接收服務的網址,或已設定的自訂對象。如果您未使用自訂目標對象,即使向特定流量代碼提出要求,aud 值仍須保留為服務的網址。

  2. 將上個步驟中擷取的 ID 權杖,新增至向接收服務提出要求時的下列其中一個標頭:

    • Authorization: Bearer ID_TOKEN 標題。
    • X-Serverless-Authorization: Bearer ID_TOKEN 標題。如果應用程式已使用 Authorization 標頭進行自訂授權,即可使用這個標頭。這樣一來,系統就會在將權杖傳遞至使用者容器前移除簽章。

如要瞭解本頁面未說明的其他 ID 權杖取得方式,請參閱「取得 ID 權杖的方法」。

使用驗證程式庫

如要取得及設定 ID 權杖程序,其中一個方法是使用驗證程式庫。這段程式碼適用於任何環境,即使在 Google Cloud以外的環境也沒問題,因為程式庫可以取得服務帳戶的驗證憑證。如要使用這個方法,請下載服務帳戶金鑰檔案,並將環境變數 GOOGLE_APPLICATION_CREDENTIALS 設為服務帳戶金鑰檔案的路徑。詳情請參閱服務帳戶金鑰

這個代碼不接受使用者帳戶的驗證憑證。

Node.js

/**
 * TODO(developer): Uncomment these variables before running the sample.
 */
// Example: https://my-cloud-run-service.run.app/books/delete/12345
// const url = 'https://TARGET_HOSTNAME/TARGET_URL';

// Example (Cloud Run): https://my-cloud-run-service.run.app/
// const targetAudience = 'https://TARGET_AUDIENCE/';

const {GoogleAuth} = require('google-auth-library');
const auth = new GoogleAuth();

async function request() {
  console.info(`request ${url} with target audience ${targetAudience}`);
  const client = await auth.getIdTokenClient(targetAudience);

  // Alternatively, one can use `client.idTokenProvider.fetchIdToken`
  // to return the ID Token.
  const res = await client.fetch(url);
  console.info(res.data);
}

request().catch(err => {
  console.error(err.message);
  process.exitCode = 1;
});

Python

import urllib

import google.auth.transport.requests
import google.oauth2.id_token


def make_authorized_get_request(endpoint, audience):
    """
    make_authorized_get_request makes a GET request to the specified HTTP endpoint
    by authenticating with the ID token obtained from the google-auth client library
    using the specified audience value.
    """

    # Cloud Run uses your service's hostname as the `audience` value
    # audience = 'https://my-cloud-run-service.run.app/'
    # For Cloud Run, `endpoint` is the URL (hostname + path) receiving the request
    # endpoint = 'https://my-cloud-run-service.run.app/my/awesome/url'

    req = urllib.request.Request(endpoint)

    auth_req = google.auth.transport.requests.Request()
    id_token = google.oauth2.id_token.fetch_id_token(auth_req, audience)

    req.add_header("Authorization", f"Bearer {id_token}")
    response = urllib.request.urlopen(req)

    return response.read()

Go


import (
	"context"
	"fmt"
	"io"

	"google.golang.org/api/idtoken"
)

// `makeGetRequest` makes a request to the provided `targetURL`
// with an authenticated client using audience `audience`.
func makeGetRequest(w io.Writer, targetURL string, audience string) error {
	// Example `audience` value (Cloud Run): https://my-cloud-run-service.run.app/
	// (`targetURL` and `audience` will differ for non-root URLs and GET parameters)
	ctx := context.Background()

	// client is a http.Client that automatically adds an "Authorization" header
	// to any requests made.
	client, err := idtoken.NewClient(ctx, audience)
	if err != nil {
		return fmt.Errorf("idtoken.NewClient: %w", err)
	}

	resp, err := client.Get(targetURL)
	if err != nil {
		return fmt.Errorf("client.Get: %w", err)
	}
	defer resp.Body.Close()
	if _, err := io.Copy(w, resp.Body); err != nil {
		return fmt.Errorf("io.Copy: %w", err)
	}

	return nil
}

Java

import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.IdTokenCredentials;
import com.google.auth.oauth2.IdTokenProvider;
import java.io.IOException;

public class Authentication {

  // makeGetRequest makes a GET request to the specified Cloud Run or
  // Cloud Functions endpoint `serviceUrl` (must be a complete URL), by
  // authenticating with an ID token retrieved from Application Default
  // Credentials using the specified `audience`.
  //
  // Example `audience` value (Cloud Run): https://my-cloud-run-service.run.app/
  public static HttpResponse makeGetRequest(String serviceUrl, String audience) throws IOException {
    GoogleCredentials credentials = GoogleCredentials.getApplicationDefault();
    if (!(credentials instanceof IdTokenProvider)) {
      throw new IllegalArgumentException("Credentials are not an instance of IdTokenProvider.");
    }
    IdTokenCredentials tokenCredential =
        IdTokenCredentials.newBuilder()
            .setIdTokenProvider((IdTokenProvider) credentials)
            .setTargetAudience(audience)
            .build();

    GenericUrl genericUrl = new GenericUrl(serviceUrl);
    HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(tokenCredential);
    HttpTransport transport = new NetHttpTransport();
    HttpRequest request = transport.createRequestFactory(adapter).buildGetRequest(genericUrl);
    return request.execute();
  }
}

使用中繼資料伺服器

如果因為某些原因無法使用驗證程式庫,您可以在容器於 Cloud Run 上執行時,從 Compute 中繼資料伺服器擷取 ID 權杖。請注意,這個方法無法在 Google Cloud以外使用,包括本機。

curl "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=[AUDIENCE]" \
     -H "Metadata-Flavor: Google"

其中 AUDIENCE 是您要叫用的服務網址,或是已設定的自訂對象

下表摘要說明中繼資料查詢要求的主要部分:

元件 說明
根網址

所有中繼資料值都會定義為下列根網址下的子路徑:

http://metadata.google.internal/computeMetadata/v1
要求標頭

每個要求都必須包含下列標頭:

Metadata-Flavor: Google

此標頭指示是在擷取中繼資料值的意圖之下傳送要求,而非隨意從不安全的來源傳送,並允許中繼資料伺服器傳回您要求的資料。如果您不提供此標頭,中繼資料伺服器會拒絕您的要求。

如要逐步瞭解如何使用這項服務對服務驗證技術,請參閱保護 Cloud Run 服務安全教學課程

從外部使用 Workload Identity 聯盟 Google Cloud

如果您的環境使用工作負載身分聯盟支援的識別資訊提供者,可以透過下列方法從外部 Google Cloud安全地向 Cloud Run 服務進行驗證:

  1. 按照本頁「設定服務帳戶」一節的說明,設定服務帳戶。

  2. 按照「設定 Workload Identity 聯盟」一文的說明,為識別資訊提供者設定 Workload Identity 聯盟。

  3. 按照「授予外部身分模擬服務帳戶的權限」一文中的操作說明進行。

  4. 使用 REST API 取得短期權杖,但請呼叫 generateIdToken 取得 ID 權杖,而非呼叫 generateAccessToken 取得存取權杖。

    例如,使用 cURL:

    ID_TOKEN=$(curl -0 -X POST https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/SERVICE_ACCOUNT:generateIdToken \
      -H "Content-Type: text/json; charset=utf-8" \
      -H "Authorization: Bearer $STS_TOKEN" \
      -d @- <<EOF | jq -r .token
      {
          "audience": "SERVICE_URL"
      }
    EOF
    )
    echo $ID_TOKEN

    其中 SERVICE_ACCOUNT 是工作負載身分集區設定要存取的服務帳戶電子郵件地址,SERVICE_URL 則是您要叫用的 Cloud Run 服務網址。即使向特定流量代碼提出要求,這個值仍應維持服務的網址。$STS_TOKEN 是您在 Workload Identity Federation 指示中,於上一個步驟收到的 Security Token Service 權杖。

您可以使用 Authorization: Bearer ID_TOKEN 標頭或 X-Serverless-Authorization: Bearer ID_TOKEN 標頭,在向服務提出的要求中加入上一個步驟的 ID 權杖。如果同時提供這兩個標頭,系統只會檢查 X-Serverless-Authorization 標頭。

使用從外部下載的服務帳戶金鑰 Google Cloud

如果 Workload Identity 聯盟不適合您的環境,您可以下載服務帳戶金鑰,從Google Cloud外部進行驗證。更新用戶端程式碼,使用先前所述的驗證程式庫。詳情請參閱服務帳戶金鑰

您可以使用自行簽署的 JWT 取得 Google 簽署的 ID 符記,但這相當複雜,而且可能容易出錯。基本步驟如下:

  1. 自行簽署服務帳戶 JWT,並將 target_audience 憑證附加資訊設為接收服務的網址或已設定的自訂目標對象。如果未使用自訂網域,即使向特定流量代碼發出要求,target_audience 值仍應維持服務的網址。

  2. 使用自行簽署的 JWT 交換 Google 簽署的 ID 符記,這個符記應已將 aud 憑證附加資訊設定為上述網址。

  3. 使用 Authorization: Bearer ID_TOKEN 標頭或 X-Serverless-Authorization: Bearer ID_TOKEN 標頭,在向服務提出的要求中加入 ID 符記。如果提供兩個標頭,系統只會檢查 X-Serverless-Authorization 標頭。

接收已驗證的要求

在接收端私人服務中,您可以剖析授權標頭,接收 Bearer 權杖傳送的資訊。

Python

from flask import Request

from google.auth.exceptions import GoogleAuthError
from google.auth.transport import requests
from google.oauth2 import id_token


def receive_request_and_parse_auth_header(request: Request) -> str:
    """Parse the authorization header, validate the Bearer token
    and decode the token to get its information.

    Args:
        request: Flask request object.

    Returns:
        One of the following:
        a) The email from the request's Authorization header.
        b) A welcome message for anonymous users.
        c) An error description.
    """
    auth_header = request.headers.get("Authorization")
    if auth_header:
        # Split the auth type and value from the header.
        auth_type, creds = auth_header.split(" ", 1)

        if auth_type.lower() == "bearer":
            # Find more information about `verify_token` function here:
            # https://google-auth.readthedocs.io/en/master/reference/google.oauth2.id_token.html#google.oauth2.id_token.verify_token
            try:
                decoded_token = id_token.verify_token(creds, requests.Request())
                return f"Hello, {decoded_token['email']}!\n"
            except GoogleAuthError as e:
                return f"Invalid token: {e}\n"
        else:
            return f"Unhandled header format ({auth_type}).\n"

    return "Hello, anonymous user.\n"

後續步驟