对服务到服务进行身份验证

如果您的架构使用多项服务,则这些服务可能需要使用异步或同步方式相互通信。其中许多服务可能是私有的,因此需要凭据才能访问

对于异步通信,您可以使用以下 Google Cloud 服务:

  • Cloud Tasks(用于一对一异步通信)
  • Pub/Sub(用于一对多、一对一和多对一异步通信)
  • Cloud Scheduler(用于定期安排的异步通信)
  • Eventarc(用于基于事件的通信)

在所有这些情况下,使用的服务都会根据您设置的配置管理与接收服务的交互。

但对于同步通信,您的服务会使用其端点网址通过 HTTP 直接调用其他服务。对于此使用场景,您应确保每项服务只能向特定服务发出请求。例如,如果您拥有 login 服务,则该服务应该能够访问 user-profiles 服务,但不能访问 search 服务。

在这种情况下,Google 建议您使用基于每项服务的用户管理服务账号IAM 和服务身份,该服务账号已被授予执行其工作所需的一组最低权限

此外,该请求还必须提供发起调用的服务的身份证明。为此,请配置您的发起调用的服务,以将 Google 签名的 OpenID Connect ID 令牌添加到请求中。

设置服务账号

如需设置服务账号,请将接收服务配置为接受来自调用服务的请求,方法是将调用服务的服务账号设为接收服务的主账号。然后,向该服务账号授予 Cloud Run Invoker (roles/run.invoker) 角色。如需执行这两项任务,请按照相应标签页中的说明进行操作:

控制台界面

  1. 前往 Google Cloud 控制台:

    前往 Google Cloud 控制台

  2. 选择接收方服务。

  3. 点击右上角的显示信息面板,以显示权限标签页。

  4. 点击添加主账号

    1. 输入调用服务的身份。默认情况下,通常是一个电子邮件地址 (PROJECT_NUMBER-compute@developer.gserviceaccount.com)。

    2. 选择角色下拉菜单中选择 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 代码会创建要公开的初始 Cloud Run 服务。

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

  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"

  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.request({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 服务安全

从 Google Cloud 外部使用工作负载身份联合

如果您的环境使用工作负载身份联合支持的身份提供方,则可以使用以下方法从 Google Cloud 外部安全地向 Cloud Run 服务进行身份验证:

  1. 按照本页面上的设置服务账号中的说明设置您的服务账号。

  2. 按照配置工作负载身份联合中的说明,为您的身份提供商配置工作负载身份联合。

  3. 按照授予外部身份模拟服务账号的权限中的说明操作。

  4. 使用 REST API 获取短期有效的令牌,但不是调用 generateAccessToken 来获取访问令牌,而是调用 generateIdToken 来获取 ID 令牌。

    例如,使用 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 @- <&ltEOF | jq -r .token
      {
          "audience": "SERVICE_URL"
      }
    EOF
    )
    echo $ID_TOKEN

    其中,SERVICE_ACCOUNT 是工作负载身份池配置来访问的服务账号的电子邮件地址,SERVICE_URL 是您正在调用的 Cloud Run 服务的网址。此值应保留为服务的网址,即使向特定流量标记发出请求也是如此。$STS_TOKEN 是您在工作负载身份联合说明的上一步中收到的 Security Token Service 令牌。

您可以使用 Authorization: Bearer ID_TOKEN 标头或 X-Serverless-Authorization: Bearer ID_TOKEN 标头将上一步中的 ID 令牌添加到对服务的请求中。如果同时提供了这两个标头,则系统仅会检查 X-Serverless-Authorization 标头。

从 Google Cloud 外部使用下载的服务账号密钥

如果工作负载身份联合不适合您的环境,您可以使用下载的服务账号密钥从 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 标头。

您可以查看此 Cloud Functions 示例,获取上述步骤的示例。

接收通过身份验证的请求

在接收专用服务中,您可以解析授权标头以接收不记名令牌发送的信息。

Python


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

def receive_authorized_get_request(request):
    """Parse the authorization header and decode the information
    being sent by the Bearer token.

    Args:
        request: Flask request object

    Returns:
        The email from the request's Authorization header.
    """
    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":
            claims = id_token.verify_token(creds, requests.Request())
            return f"Hello, {claims['email']}!\n"

        else:
            return f"Unhandled header format ({auth_type}).\n"
    return "Hello, anonymous user.\n"