服务之间的身份验证

除了对用户进行身份验证之外,您可能还需要允许其他服务与您的 API 进行交互。虽然客户端应用可以为用户提供网页登录提示,要求用户提交凭据进行身份验证,但您仍然需要另一种方法来确保安全的服务到服务通信。本页介绍了我们建议的在服务之间实现身份验证的方法,并提供了示例代码。

概览

要识别向您的 API 发送请求的服务,请使用服务帐号。 调用服务会使用服务帐号的私钥来签署安全的 JSON Web 令牌 (JWT),并将已签名的 JWT 随请求发送到您的 API。

要在您的 API 和调用服务中实现服务到服务身份验证,请执行以下操作:

  1. 为调用的服务创建服务帐号和密钥。
  2. 在 Cloud Endpoints 服务的 OpenAPI 文档中添加对身份验证的支持。
  3. 将代码添加到调用服务:

    • 创建 JWT 并使用服务帐号的私钥为其签名。
    • 将签名的 JWT 随请求发送到 API。

ESP 会在将请求转发给您的 API 之前验证 JWT 中的声明是否与 OpenAPI 文档中的配置相匹配。ESP 不会检查您已经在服务帐号上授予的 Cloud Identity 权限。

前提条件

本页面假定您已经完成以下操作:

使用密钥创建服务帐号

您需要一个包含调用服务用于签署 JWT 的私钥文件的服务帐号。如果有多个服务向您的 API 发送请求,您可以创建一个服务帐号来代表所有调用服务。如果您需要区分这些服务(例如,它们可能具有不同的权限),您可以为每个调用服务分别创建服务帐号和密钥。

本部分介绍如何使用 Google Cloud Console 和 gcloud 命令行工具来创建服务帐号和私钥文件,以及为服务帐号分配 Service Account Token Creator 角色。如需了解如何使用 API 执行此任务,请参阅创建和管理服务帐号

如需创建服务帐号和密钥,请执行以下操作:

Cloud Console

  1. 创建服务帐号:

    1. 在 Cloud Console 中,转到创建服务帐号页面。

      转到“创建服务帐号”页面

    2. 选择要使用的项目。

    3. 服务帐号名称字段中,输入一个名称。

    4. 可选:在服务帐号说明字段中,输入说明。

    5. 点击创建

    6. 点击选择角色字段。在所有角色下,选择服务帐号 > Service Account Token Creator

    7. 点击完成

      不要关闭浏览器窗口。您将在下一步骤中用到它。

  2. 创建服务帐号密钥:

    1. 在 Cloud Console 中,点击您创建的服务帐号的电子邮件地址。
    2. 点击密钥
    3. 依次点击添加密钥创建新密钥
    4. 点击创建。包含服务帐号私钥的 JSON 文件就会下载到您的计算机。
    5. 点击关闭

gcloud

您可以使用本地机器上的 Cloud SDK 或在 Cloud Shell 中运行以下命令。

  1. 设置 gcloud 的默认帐号。如果您有多个帐号,请务必选择您要使用的 Google Cloud 项目中的帐号。

    gcloud auth login
    
  2. 显示您的 Google Cloud 项目的 ID:

    gcloud projects list
    
  3. 设置默认项目。将 PROJECT_ID 替换为您要使用的 Google Cloud 项目 ID。

    gcloud config set project PROJECT_ID
  4. 创建一个服务帐号。将 SA_NAMESA_DISPLAY_NAME 替换为您要使用的名称和显示名。

    gcloud iam service-accounts create SA_NAME \
      --display-name "SA_DISPLAY_NAME"
    
  5. 显示刚刚创建的服务帐号的电子邮件地址。

    gcloud iam service-accounts list
    
  6. 添加 Service Account Token Creator 角色。将 SA_EMAIL_ADDRESS 替换为服务帐号的电子邮件地址。

    gcloud projects add-iam-policy-binding PROJECT_ID \
      --member serviceAccount:SA_EMAIL_ADDRESS \
      --role roles/iam.serviceAccountTokenCreator
    
  7. 在当前工作目录中创建服务帐号密钥文件。 将 FILE_NAME 替换为您要用于密钥文件的名称。默认情况下,gcloud 命令会创建一个 JSON 文件。

    gcloud iam service-accounts keys create FILE_NAME.json \
      --iam-account SA_EMAIL_ADDRESS
    

如需详细了解以上命令,请参阅 gcloud 参考

如需了解如何对私钥采取保护措施,请参阅管理凭据的最佳做法

配置 API 以支持身份验证

OpenAPI 文档中必须包含安全要求对象安全性定义对象,ESP 才能验证签名的 JWT 中的声明。

  1. 在 OpenAPI 文档中将服务帐号添加为颁发者。

    securityDefinitions:
      DEFINITION_NAME:
        authorizationUrl: ""
        flow: "implicit"
        type: "oauth2"
        x-google-issuer: "SA_EMAIL_ADDRESS"
        x-google-jwks_uri: "https://www.googleapis.com/robot/v1/metadata/x509/SA_EMAIL_ADDRESS"
    
    • DEFINITION_NAME 替换为标识此安全性定义的字符串。您可能希望将其替换为服务帐号名称或标识调用服务的名称。
    • SA_EMAIL_ADDRESS 替换为服务帐号的电子邮件地址。
    • 您可以在 OpenAPI 文档中确立多个安全性定义,但每个定义必须具有不同的 x-google-issuer。如果您为每个调用服务创建了单独的服务帐号,那么您可以为每个服务帐号创建安全性定义,例如:
    securityDefinitions:
      service-1:
        authorizationUrl: ""
        flow: "implicit"
        type: "oauth2"
        x-google-issuer: "service-1@example-project-12345.iam.gserviceaccount.com"
        x-google-jwks_uri: "https://www.googleapis.com/robot/v1/metadata/x509/service-1@example-project-12345.iam.gserviceaccount.com"
      service-2:
        authorizationUrl: ""
        flow: "implicit"
        type: "oauth2"
        x-google-issuer: "service-2@example-project-12345.iam.gserviceaccount.com"
        x-google-jwks_uri: "https://www.googleapis.com/robot/v1/metadata/x509/service-2@example-project-12345.iam.gserviceaccount.com"
    
  2. 您可以选择将 x-google-audiences 添加到 securityDefinitions 部分。如果您不添加 x-google-audiences,则 ESP 要求 JWT 中的 "aud"(目标对象)声明采用 https://SERVICE_NAME 格式,其中 SERVICE_NAME 是您已在 OpenAPI 文档的 host 字段中配置的 Endpoints 服务名称。

  3. 您可以选择将 x-google-jwt-locations 添加到 securityDefinitions 部分。您可以使用此值指定自定义 JWT 位置。默认 JWT 位置是 Authorization 标头(带有“Bearer”前缀)、X-Goog-Iap-Jwt-Assertion 标头或 access_token 查询参数。请注意:

    • 如果指定了 x-google-jwt-locations,则 Endpoints 会忽略所有默认位置。
    • 只有 ESPv2 支持 x-google-jwt-locations
  4. 在文件的顶层位置添加 security 部分(不缩进或嵌套)以应用于整个 API,或者在方法级层添加以应用于特定方法。如果同时在 API 级层和方法级层使用 security 部分,则方法级层的设置将替换 API 级层的设置。

    security:
      - DEFINITION_NAME: []
    
    • DEFINITION_NAME 替换为您在 securityDefinitions 部分中使用的名称。
    • 如果您在 securityDefinitions 部分有多个定义,请将它们添加到 security 部分,例如:

      security:
        - service-1: []
        - service-2: []
      
  5. 部署更新的 OpenAPI 文档。将 OPENAPI_DOC 替换为 OpenAPI 文档的名称。

    gcloud endpoints services deploy OPENAPI_DOC

在 ESP 向您的 API 转发请求之前,ESP 会验证以下几个方面:

  • 使用公钥来验证 JWT 的签名,该签名位于 OpenAPI 文档的 x-google-jwks_uri 字段中指定的 URI 处。
  • 验证 JWT 中的 "iss"(颁发者)声明与 x-google-issuer 字段中指定的值是否匹配。
  • 验证 JWT 中的 "aud"(目标对象)声明是否包含您的 Endpoints 服务名称或与您在 x-google-audiences 字段中指定的任一值匹配。
  • 使用 "exp"(到期时间)声明来验证令牌是否未到期。

如需详细了解 x-google-issuerx-google-jwks_urix-google-audiencesx-google-jwt-locations,请参阅OpenAPI 扩展程序

向 Endpoints API 发出经过身份验证的请求

要发送经过身份验证的请求,调用服务会发送用您在 OpenAPI 文档中指定的服务帐号签名的JWT。调用服务必须:

  1. 创建 JWT 并使用服务帐号的私钥对其进行签名。
  2. 将签名的 JWT 随请求发送到 API。

以下示例代码演示了此过程如何选取语言。如要使用其他语言发出经过身份验证的请求,请参阅 jwt.io 以获取支持的库列表。

  1. 在调用服务中,添加以下函数并将以下参数传递给它:
    Java
    • saKeyfile:服务帐号私钥文件的完整路径。
    • saEmail:服务帐号的电子邮件地址。
    • audience:如果您将 x-google-audiences 字段添加到了 OpenAPI 文档,请将 audience 设置为您为 x-google-audiences 指定的值之一。否则,请将 audience 设置为 https://SERVICE_NAME,其中 SERVICE_NAME 是 Endpoints 服务名称。
    • expiryLength:JWT 的到期时间,以秒为单位。
    Python
    • sa_keyfile:服务帐号私钥文件的完整路径。
    • sa_email:服务帐号的电子邮件地址。
    • audience:如果您将 x-google-audiences 字段添加到了 OpenAPI 文档,请将 audience 设置为您为 x-google-audiences 指定的值之一。否则,请将 audience 设置为 https://SERVICE_NAME,其中 SERVICE_NAME 是 Endpoints 服务名称。
    • expiry_length:JWT 的到期时间,以秒为单位。
    Go
    • saKeyfile:服务帐号私钥文件的完整路径。
    • saEmail:服务帐号的电子邮件地址。
    • audience:如果您将 x-google-audiences 字段添加到了 OpenAPI 文档,请将 audience 设置为您为 x-google-audiences 指定的值之一。否则,请将 audience 设置为 https://SERVICE_NAME,其中 SERVICE_NAME 是 Endpoints 服务名称。
    • expiryLength:JWT 的到期时间,以秒为单位。

    该函数会创建一个 JWT,使用私钥文件对其进行签名,并返回已签名的 JWT。

    Java
    /**
     * Generates a signed JSON Web Token using a Google API Service Account
     * utilizes com.auth0.jwt.
     */
    public static String generateJwt(final String saKeyfile, final String saEmail,
        final String audience, final int expiryLength)
        throws FileNotFoundException, IOException {
    
      Date now = new Date();
      Date expTime = new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(expiryLength));
    
      // Build the JWT payload
      JWTCreator.Builder token = JWT.create()
          .withIssuedAt(now)
          // Expires after 'expiraryLength' seconds
          .withExpiresAt(expTime)
          // Must match 'issuer' in the security configuration in your
          // swagger spec (e.g. service account email)
          .withIssuer(saEmail)
          // Must be either your Endpoints service name, or match the value
          // specified as the 'x-google-audience' in the OpenAPI document
          .withAudience(audience)
          // Subject and email should match the service account's email
          .withSubject(saEmail)
          .withClaim("email", saEmail);
    
      // Sign the JWT with a service account
      FileInputStream stream = new FileInputStream(saKeyfile);
      ServiceAccountCredentials cred = ServiceAccountCredentials.fromStream(stream);
      RSAPrivateKey key = (RSAPrivateKey) cred.getPrivateKey();
      Algorithm algorithm = Algorithm.RSA256(null, key);
      return token.sign(algorithm);
    }
    Python
    def generate_jwt(sa_keyfile,
                     sa_email='account@project-id.iam.gserviceaccount.com',
                     audience='your-service-name',
                     expiry_length=3600):
    
        """Generates a signed JSON Web Token using a Google API Service Account."""
    
        now = int(time.time())
    
        # build payload
        payload = {
            'iat': now,
            # expires after 'expiry_length' seconds.
            "exp": now + expiry_length,
            # iss must match 'issuer' in the security configuration in your
            # swagger spec (e.g. service account email). It can be any string.
            'iss': sa_email,
            # aud must be either your Endpoints service name, or match the value
            # specified as the 'x-google-audience' in the OpenAPI document.
            'aud':  audience,
            # sub and email should match the service account's email address
            'sub': sa_email,
            'email': sa_email
        }
    
        # sign with keyfile
        signer = google.auth.crypt.RSASigner.from_service_account_file(sa_keyfile)
        jwt = google.auth.jwt.encode(signer, payload)
    
        return jwt
    Go
    
    // generateJWT creates a signed JSON Web Token using a Google API Service Account.
    func generateJWT(saKeyfile, saEmail, audience string, expiryLength int64) (string, error) {
    	now := time.Now().Unix()
    
    	// Build the JWT payload.
    	jwt := &jws.ClaimSet{
    		Iat: now,
    		// expires after 'expiryLength' seconds.
    		Exp: now + expiryLength,
    		// Iss must match 'issuer' in the security configuration in your
    		// swagger spec (e.g. service account email). It can be any string.
    		Iss: saEmail,
    		// Aud must be either your Endpoints service name, or match the value
    		// specified as the 'x-google-audience' in the OpenAPI document.
    		Aud: audience,
    		// Sub and Email should match the service account's email address.
    		Sub:           saEmail,
    		PrivateClaims: map[string]interface{}{"email": saEmail},
    	}
    	jwsHeader := &jws.Header{
    		Algorithm: "RS256",
    		Typ:       "JWT",
    	}
    
    	// Extract the RSA private key from the service account keyfile.
    	sa, err := ioutil.ReadFile(saKeyfile)
    	if err != nil {
    		return "", fmt.Errorf("Could not read service account file: %v", err)
    	}
    	conf, err := google.JWTConfigFromJSON(sa)
    	if err != nil {
    		return "", fmt.Errorf("Could not parse service account JSON: %v", err)
    	}
    	block, _ := pem.Decode(conf.PrivateKey)
    	parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    	if err != nil {
    		return "", fmt.Errorf("private key parse error: %v", err)
    	}
    	rsaKey, ok := parsedKey.(*rsa.PrivateKey)
    	// Sign the JWT with the service account's private key.
    	if !ok {
    		return "", errors.New("private key failed rsa.PrivateKey type assertion")
    	}
    	return jws.Encode(jwsHeader, jwt, rsaKey)
    }
    
  2. 在调用服务中,添加以下函数,将签名的 JWT 随请求的 Authorization: Bearer 标头发送到 API:
    Java
    /**
     * Makes an authorized request to the endpoint.
     */
    public static String makeJwtRequest(final String signedJwt, final URL url)
        throws IOException, ProtocolException {
    
      HttpURLConnection con = (HttpURLConnection) url.openConnection();
      con.setRequestMethod("GET");
      con.setRequestProperty("Content-Type", "application/json");
      con.setRequestProperty("Authorization", "Bearer " + signedJwt);
    
      InputStreamReader reader = new InputStreamReader(con.getInputStream());
      BufferedReader buffReader = new BufferedReader(reader);
    
      String line;
      StringBuilder result = new StringBuilder();
      while ((line = buffReader.readLine()) != null) {
        result.append(line);
      }
      buffReader.close();
      return result.toString();
    }
    Python
    def make_jwt_request(signed_jwt, url='https://your-endpoint.com'):
        """Makes an authorized request to the endpoint"""
        headers = {
            'Authorization': 'Bearer {}'.format(signed_jwt.decode('utf-8')),
            'content-type': 'application/json'
        }
        response = requests.get(url, headers=headers)
        print(response.status_code, response.content)
        response.raise_for_status()
    
    Go
    
    // makeJWTRequest sends an authorized request to your deployed endpoint.
    func makeJWTRequest(signedJWT, url string) (string, error) {
    	client := &http.Client{
    		Timeout: 10 * time.Second,
    	}
    
    	req, err := http.NewRequest("GET", url, nil)
    	if err != nil {
    		return "", fmt.Errorf("failed to create HTTP request: %v", err)
    	}
    	req.Header.Add("Authorization", "Bearer "+signedJWT)
    	req.Header.Add("content-type", "application/json")
    
    	response, err := client.Do(req)
    	if err != nil {
    		return "", fmt.Errorf("HTTP request failed: %v", err)
    	}
    	defer response.Body.Close()
    	responseData, err := ioutil.ReadAll(response.Body)
    	if err != nil {
    		return "", fmt.Errorf("failed to parse HTTP response: %v", err)
    	}
    	return string(responseData), nil
    }
    

当您使用 JWT 发送请求时,出于安全原因,我们建议您将身份验证令牌放在 Authorization: Bearer 标头中。例如:

curl --request POST \
  --header "Authorization: Bearer ${TOKEN}" \
  "${ENDPOINTS_HOST}/echo"

其中 ENDPOINTS_HOSTTOKEN 分别是包含 API 主机名和身份验证令牌的环境变量。

在 API 中接收经过验证的结果

ESP 通常会转发收到的所有标头。但是,当后端地址由 OpenAPI 规范中的 x-google-backend 或 gRPC 服务配置中的 BackendRule 指定时,它会替换原来的 Authorization 标头。

ESP 会将 X-Endpoint-API-UserInfo 中的身份验证结果发送到后端 API。我们建议您使用此标头,而不是原来的 Authorization 标头。此标头是 base64url 对以下 JSON 对象进行编码的字符串:

{
  "id": "from-sub",
  "issuer": "from-iss",
  "email": "from-email",
  "audiences": ["from-aud"],
  "claims": {
     original-jwt-payload
   }
}

如果您使用的是 ESPv2,则标头中的值格式会有所不同。有关新格式的详情,请参阅迁移到 Extensible Service Proxy V2 Beta 版

后续步骤