使用签名标头保护应用的安全

本页面介绍了如何使用签名的 IAP 标头保护应用的安全。配置 Identity-Aware Proxy (IAP) 后,该服务会使用 JSON Web 令牌 (JWT) 来确保向应用发出的请求已获得授权。这样可以保护您的应用远离以下种类的风险:

  • IAP 被意外停用;
  • 防火墙配置有误;
  • 来自项目内部的访问。

要妥善保护应用的安全,您必须对所有应用类型使用签名标头。

或者,如果您有 App Engine 标准环境应用,则可以使用 Users API

请注意,Compute Engine 和 GKE 健康检查不包含 JWT 标头,而 IAP 不会处理健康检查。如果您的健康检查返回访问错误,请确保已在 Google Cloud 控制台中对其进行了正确配置,并且 JWT 标头验证将健康检查路径列入了许可名单。如需了解详情,请参阅创建健康检查例外

准备工作

要使用签名标头保护应用的安全,您需要做好以下准备:

使用 IAP 标头保护应用的安全

为了使用 IAP JWT 保护您的应用,请验证 JWT 的标头、载荷和签名。JWT 在 HTTP 请求标头 x-goog-iap-jwt-assertion 中。如果攻击者绕过 IAP,他们便能够伪造没有签名的 IAP 身份标头 x-goog-authenticated-user-{email,id}。JWT 可提供一种更加安全的替代解决方案。

当有人绕过 IAP 时,签名标头可提供额外的安全保护。请注意,IAP 启用后,它会在请求通过 IAP 服务基础架构时删除客户端提供的 x-goog-* 标头。

验证 JWT 标头

验证 JWT 的标头是否符合以下规定:

JWT 标头声明
alg 算法 ES256
kid 密钥 ID 必须与 IAP 密钥文件中列出的公钥之一相对应,并以两种不同的格式提供:https://www.gstatic.com/iap/verify/public_keyhttps://www.gstatic.com/iap/verify/public_key-jwk

请确保 JWT 已由与令牌的 kid 声明相对应的私钥签名。为此,请首先从以下任一位置中获取公钥:

  • https://www.gstatic.com/iap/verify/public_key。该网址包含一个 JSON 字典,该字典将 kid 声明映射到公钥值。
  • https://www.gstatic.com/iap/verify/public_key-jwk。该网址包含 JWK 格式的 IAP 公钥。

获取公钥后,请使用 JWT 库来验证签名。

验证 JWT 载荷

验证 JWT 的载荷是否符合以下规定:

JWT 负载声明
exp 到期时间 必须是将来的时间。时间从 UNIX 计时原点开始计算,以秒为单位。允许 30 秒偏差。 令牌的最长生命周期为 10 分钟 + 2 * 偏差。
iat 颁发时间 必须是过去的时间。时间从 UNIX 计时原点开始计算,以秒为单位。允许 30 秒偏差。
aud 受众 必须是一个含有以下值的字符串:
  • App Engine:/projects/PROJECT_NUMBER/apps/PROJECT_ID
  • Compute Engine 和 GKE: /projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID
iss 颁发者 必须为 https://cloud.google.com/iap
hd 账号网域 如果账号属于一个托管网域,则会提供 hd 声明来区分与该账号关联的网域。
google Google 声明 如果一个或多个访问权限级别适用于请求,则这些级别的名称将以字符串数组形式存储在 google 声明 JSON 对象的 access_levels 键下。

指定设备政策且组织有权访问设备数据时,DeviceId 也会存储在 JSON 对象中。请注意,发送到其他组织的请求可能无权查看设备数据。

要获取上述 aud 字符串的值,您可以访问 Google Cloud 控制台,也可以使用 gcloud 命令行工具。

如需通过 Google Cloud 控制台获取 aud 字符串值,请前往项目的 Identity-Aware Proxy 设置,点击负载平衡器资源旁边的更多,然后选择签名标头 JWT 目标设备。出现的签名标头 JWT对话框显示所选资源的 aud 声明。

包含“签名标头 JWT 受众群体”选项的溢出菜单

如果您想使用 gcloud CLI gcloud 命令行工具来获取 aud 字符串值,则需要知道项目 ID。您可以在 Google Cloud 控制台项目信息卡片上找到项目 ID,然后为每个值运行以下指定的命令。

项目编号

要使用 gcloud 命令行工具获取项目编号,请运行以下命令:

gcloud projects describe PROJECT_ID

该命令返回如下输出:

createTime: '2016-10-13T16:44:28.170Z'
lifecycleState: ACTIVE
name: project_name
parent:
  id: '433637338589'
  type: organization
projectId: PROJECT_ID
projectNumber: 'PROJECT_NUMBER'

服务 ID

要使用 gcloud 命令行工具获取服务 ID,请运行以下命令:

gcloud compute backend-services describe SERVICE_NAME --project=PROJECT_ID --global

该命令会返回如下输出:

affinityCookieTtlSec: 0
backends:
- balancingMode: UTILIZATION
  capacityScaler: 1.0
  group: https://www.googleapis.com/compute/v1/projects/project_name/regions/us-central1/instanceGroups/my-group
connectionDraining:
  drainingTimeoutSec: 0
creationTimestamp: '2017-04-03T14:01:35.687-07:00'
description: ''
enableCDN: false
fingerprint: zaOnO4k56Cw=
healthChecks:
- https://www.googleapis.com/compute/v1/projects/project_name/global/httpsHealthChecks/my-hc
id: 'SERVICE_ID'
kind: compute#backendService
loadBalancingScheme: EXTERNAL
name: my-service
port: 8443
portName: https
protocol: HTTPS
selfLink: https://www.googleapis.com/compute/v1/projects/project_name/global/backendServices/my-service
sessionAffinity: NONE
timeoutSec: 3610

检索用户身份

如果以上所有验证均成功通过,请检索用户身份。 ID 令牌的载荷包含以下用户信息:

ID 令牌载荷用户身份
sub 主题 用户的唯一稳定标识符。请使用此值来代替 x-goog-authenticated-user-id 标头。
email 用户电子邮件地址 用户电子邮件地址。
  • 请使用此值来代替 x-goog-authenticated-user-email 标头。
  • 与该标头和 sub 声明不同,此值没有命名空间前缀。

以下是使用签名的 IAP 标头保护应用的部分示例代码:

C#


using Google.Apis.Auth;
using Google.Apis.Auth.OAuth2;
using System;
using System.Threading;
using System.Threading.Tasks;

public class IAPTokenVerification
{
    /// <summary>
    /// Verifies a signed jwt token and returns its payload.
    /// </summary>
    /// <param name="signedJwt">The token to verify.</param>
    /// <param name="expectedAudience">The audience that the token should be meant for.
    /// Validation will fail if that's not the case.</param>
    /// <param name="cancellationToken">The cancellation token to propagate cancellation requests.</param>
    /// <returns>A task that when completed will have as its result the payload of the verified token.</returns>
    /// <exception cref="InvalidJwtException">If verification failed. The message of the exception will contain
    /// information as to why the token failed.</exception>
    public async Task<JsonWebSignature.Payload> VerifyTokenAsync(
        string signedJwt, string expectedAudience, CancellationToken cancellationToken = default)
    {
        SignedTokenVerificationOptions options = new SignedTokenVerificationOptions
        {
            // Use clock tolerance to account for possible clock differences
            // between the issuer and the verifier.
            IssuedAtClockTolerance = TimeSpan.FromMinutes(1),
            ExpiryClockTolerance = TimeSpan.FromMinutes(1),
            TrustedAudiences = { expectedAudience },
            TrustedIssuers = { "https://cloud.google.com/iap" },
            CertificatesUrl = GoogleAuthConsts.IapKeySetUrl,
        };

        return await JsonWebSignature.VerifySignedTokenAsync(signedJwt, options, cancellationToken: cancellationToken);
    }
}

Go

import (
	"context"
	"fmt"
	"io"

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

// validateJWTFromAppEngine validates a JWT found in the
// "x-goog-iap-jwt-assertion" header.
func validateJWTFromAppEngine(w io.Writer, iapJWT, projectNumber, projectID string) error {
	// iapJWT := "YmFzZQ==.ZW5jb2RlZA==.and0" // req.Header.Get("X-Goog-IAP-JWT-Assertion")
	// projectNumber := "123456789"
	// projectID := "your-project-id"
	ctx := context.Background()
	aud := fmt.Sprintf("/projects/%s/apps/%s", projectNumber, projectID)

	payload, err := idtoken.Validate(ctx, iapJWT, aud)
	if err != nil {
		return fmt.Errorf("idtoken.Validate: %w", err)
	}

	// payload contains the JWT claims for further inspection or validation
	fmt.Fprintf(w, "payload: %v", payload)

	return nil
}

// validateJWTFromComputeEngine validates a JWT found in the
// "x-goog-iap-jwt-assertion" header.
func validateJWTFromComputeEngine(w io.Writer, iapJWT, projectNumber, backendServiceID string) error {
	// iapJWT := "YmFzZQ==.ZW5jb2RlZA==.and0" // req.Header.Get("X-Goog-IAP-JWT-Assertion")
	// projectNumber := "123456789"
	// backendServiceID := "backend-service-id"
	ctx := context.Background()
	aud := fmt.Sprintf("/projects/%s/global/backendServices/%s", projectNumber, backendServiceID)

	payload, err := idtoken.Validate(ctx, iapJWT, aud)
	if err != nil {
		return fmt.Errorf("idtoken.Validate: %w", err)
	}

	// payload contains the JWT claims for further inspection or validation
	fmt.Fprintf(w, "payload: %v", payload)

	return nil
}

Java


import com.google.api.client.http.HttpRequest;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.auth.oauth2.TokenVerifier;

/** Verify IAP authorization JWT token in incoming request. */
public class VerifyIapRequestHeader {

  private static final String IAP_ISSUER_URL = "https://cloud.google.com/iap";

  // Verify jwt tokens addressed to IAP protected resources on App Engine.
  // The project *number* for your Google Cloud project via 'gcloud projects describe $PROJECT_ID'
  // The project *number* can also be retrieved from the Project Info card in Cloud Console.
  // projectId is The project *ID* for your Google Cloud Project.
  boolean verifyJwtForAppEngine(HttpRequest request, long projectNumber, String projectId)
      throws Exception {
    // Check for iap jwt header in incoming request
    String jwt = request.getHeaders().getFirstHeaderStringValue("x-goog-iap-jwt-assertion");
    if (jwt == null) {
      return false;
    }
    return verifyJwt(
        jwt,
        String.format("/projects/%s/apps/%s", Long.toUnsignedString(projectNumber), projectId));
  }

  boolean verifyJwtForComputeEngine(HttpRequest request, long projectNumber, long backendServiceId)
      throws Exception {
    // Check for iap jwt header in incoming request
    String jwtToken = request.getHeaders().getFirstHeaderStringValue("x-goog-iap-jwt-assertion");
    if (jwtToken == null) {
      return false;
    }
    return verifyJwt(
        jwtToken,
        String.format(
            "/projects/%s/global/backendServices/%s",
            Long.toUnsignedString(projectNumber), Long.toUnsignedString(backendServiceId)));
  }

  private boolean verifyJwt(String jwtToken, String expectedAudience) {
    TokenVerifier tokenVerifier =
        TokenVerifier.newBuilder().setAudience(expectedAudience).setIssuer(IAP_ISSUER_URL).build();
    try {
      JsonWebToken jsonWebToken = tokenVerifier.verify(jwtToken);

      // Verify that the token contain subject and email claims
      JsonWebToken.Payload payload = jsonWebToken.getPayload();
      return payload.getSubject() != null && payload.get("email") != null;
    } catch (TokenVerifier.VerificationException e) {
      System.out.println(e.getMessage());
      return false;
    }
  }
}

Node.js

/**
 * TODO(developer): Uncomment these variables before running the sample.
 */
// const iapJwt = 'SOME_ID_TOKEN'; // JWT from the "x-goog-iap-jwt-assertion" header

let expectedAudience = null;
if (projectNumber && projectId) {
  // Expected Audience for App Engine.
  expectedAudience = `/projects/${projectNumber}/apps/${projectId}`;
} else if (projectNumber && backendServiceId) {
  // Expected Audience for Compute Engine
  expectedAudience = `/projects/${projectNumber}/global/backendServices/${backendServiceId}`;
}

const oAuth2Client = new OAuth2Client();

async function verify() {
  // Verify the id_token, and access the claims.
  const response = await oAuth2Client.getIapPublicKeys();
  const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync(
    iapJwt,
    response.pubkeys,
    expectedAudience,
    ['https://cloud.google.com/iap']
  );
  // Print out the info contained in the IAP ID token
  console.log(ticket);
}

verify().catch(console.error);

PHP

namespace Google\Cloud\Samples\Iap;

# Imports Google auth libraries for IAP validation
use Google\Auth\AccessToken;

/**
 * Validate a JWT passed to your App Engine app by Identity-Aware Proxy.
 *
 * @param string $iapJwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $cloudProjectNumber The project *number* for your Google
 *     Cloud project. This is returned by 'gcloud projects describe $PROJECT_ID',
 *     or in the Project Info card in Cloud Console.
 * @param string $cloudProjectId Your Google Cloud Project ID.
 */
function validate_jwt_from_app_engine(
    string $iapJwt,
    string $cloudProjectNumber,
    string $cloudProjectId
): void {
    $expectedAudience = sprintf(
        '/projects/%s/apps/%s',
        $cloudProjectNumber,
        $cloudProjectId
    );
    validate_jwt($iapJwt, $expectedAudience);
}

/**
 * Validate a JWT passed to your Compute / Container Engine app by Identity-Aware Proxy.
 *
 * @param string $iapJwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $cloudProjectNumber The project *number* for your Google
 *     Cloud project. This is returned by 'gcloud projects describe $PROJECT_ID',
 *     or in the Project Info card in Cloud Console.
 * @param string $backendServiceId The ID of the backend service used to access the
 *     application. See https://cloud.google.com/iap/docs/signed-headers-howto
 *     for details on how to get this value.
 */
function validate_jwt_from_compute_engine(
    string $iapJwt,
    string $cloudProjectNumber,
    string $backendServiceId
): void {
    $expectedAudience = sprintf(
        '/projects/%s/global/backendServices/%s',
        $cloudProjectNumber,
        $backendServiceId
    );
    validate_jwt($iapJwt, $expectedAudience);
}

/**
 * Validate a JWT passed to your app by Identity-Aware Proxy.
 *
 * @param string $iapJwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $expectedAudience The expected audience of the JWT with the following formats:
 *     App Engine:     /projects/{PROJECT_NUMBER}/apps/{PROJECT_ID}
 *     Compute Engine: /projects/{PROJECT_NUMBER}/global/backendServices/{BACKEND_SERVICE_ID}
 */
function validate_jwt(string $iapJwt, string $expectedAudience): void
{
    // Validate the signature using the IAP cert URL.
    $token = new AccessToken();
    $jwt = $token->verify($iapJwt, [
        'certsLocation' => AccessToken::IAP_CERT_URL
    ]);

    if (!$jwt) {
        print('Failed to validate JWT: Invalid JWT');
        return;
    }

    // Validate token by checking issuer and audience fields.
    assert($jwt['iss'] == 'https://cloud.google.com/iap');
    assert($jwt['aud'] == $expectedAudience);

    print('Printing user identity information from ID token payload:');
    printf('sub: %s', $jwt['sub']);
    printf('email: %s', $jwt['email']);
}

Python

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


def validate_iap_jwt(iap_jwt, expected_audience):
    """Validate an IAP JWT.

    Args:
      iap_jwt: The contents of the X-Goog-IAP-JWT-Assertion header.
      expected_audience: The Signed Header JWT audience. See
          https://cloud.google.com/iap/docs/signed-headers-howto
          for details on how to get this value.

    Returns:
      (user_id, user_email, error_str).
    """

    try:
        decoded_jwt = id_token.verify_token(
            iap_jwt,
            requests.Request(),
            audience=expected_audience,
            certs_url="https://www.gstatic.com/iap/verify/public_key",
        )
        return (decoded_jwt["sub"], decoded_jwt["email"], "")
    except Exception as e:
        return (None, None, f"**ERROR: JWT validation error {e}**")

Ruby

# iap_jwt = "The contents of the X-Goog-Iap-Jwt-Assertion header"
# project_number = "The project *number* for your Google Cloud project"
# project_id = "Your Google Cloud project ID"
# backend_service_id = "Your Compute Engine backend service ID"
require "googleauth"

audience = nil
if project_number && project_id
  # Expected audience for App Engine
  audience = "/projects/#{project_number}/apps/#{project_id}"
elsif project_number && backend_service_id
  # Expected audience for Compute Engine
  audience = "/projects/#{project_number}/global/backendServices/#{backend_service_id}"
end

# The client ID as the target audience for IAP
payload = Google::Auth::IDTokens.verify_iap iap_jwt, aud: audience

puts payload

if audience.nil?
  puts "Audience not verified! Supply a project_number and project_id to verify"
end

测试您的验证代码

如果您使用 secure_token_test 查询参数访问应用,则 IAP 将包含无效的 JWT。使用此方法可以确保 JWT 验证逻辑处理各种失败情况,并了解您的应用在收到无效 JWT 时的行为方式。

创建健康检查例外

如前所述,Compute Engine 和 GKE 健康检查不使用 JWT 标头,而 IAP 不会处理健康检查。要允许使用此类健康检查,您需要对健康检查和应用进行配置。

配置健康检查

如果您尚未设置健康检查路径,请使用 Google Cloud 控制台为健康检查设置一条非敏感路径。确保此路径未分享给其他任何资源。

  1. 前往 Google Cloud 控制台中的健康检查页面。
    转到“健康检查”页面
  2. 点击您要在应用中使用的健康检查,然后点击修改
  3. 请求路径下,添加非敏感路径的名称。这用于指定在发送健康检查请求时 Google Cloud 使用的网址路径。如果省略,则健康检查请求将发送到 /
  4. 点击保存

配置 JWT 验证

在调用 JWT 验证例程的代码中,添加一个条件,以便让系统返回 200 HTTP 状态来响应您的健康检查请求路径。例如:

if HttpRequest.path_info = '/HEALTH_CHECK_REQUEST_PATH'
  return HttpResponse(status=200)
else
  VALIDATION_FUNCTION

外部身份的 JWT

如果您将 IAP 与外部身份一起使用,则 IAP 仍然会针对每个经过身份验证的请求发出已签名的 JWT,就像处理 Google 身份一样。不过,也有一些区别。

提供商信息

当使用外部身份时,JWT 有效载荷将包含一个名为 gcip 的声明。此声明包含有关用户的信息,例如他们的电子邮件地址和照片网址,以及任何其他提供商特定的属性。

以下是使用 Facebook 登录的用户的 JWT 示例:

"gcip": '{
  "auth_time": 1553219869,
  "email": "facebook_user@gmail.com",
  "email_verified": false,
  "firebase": {
    "identities": {
      "email": [
        "facebook_user@gmail.com"
      ],
      "facebook.com": [
        "1234567890"
      ]
    },
    "sign_in_provider": "facebook.com",
  },
  "name": "Facebook User",
  "picture: "https://graph.facebook.com/1234567890/picture",
  "sub": "gZG0yELPypZElTmAT9I55prjHg63"
}',

emailsub 字段

如果用户已通过 Identity Platform 认证,则 JWT 的 emailsub 字段将以 Identity Platform 令牌颁发者和所使用的租户 ID(如果有)为前缀。例如:

"email": "securetoken.google.com/PROJECT-ID/TENANT-ID:demo_user@gmail.com",
"sub": "securetoken.google.com/PROJECT-ID/TENANT-ID:gZG0yELPypZElTmAT9I55prjHg63"

sign_in_attributes 控制访问权限

外部身份不支持 IAM ,但您可以改为使用嵌入在 sign_in_attributes 字段中的声明来控制访问权限。例如,考虑使用 SAML 提供商登录的用户:

{
  "aud": "/projects/project_number/apps/my_project_id",
  "gcip": '{
    "auth_time": 1553219869,
    "email": "demo_user@gmail.com",
    "email_verified": true,
    "firebase": {
      "identities": {
        "email": [
          "demo_user@gmail.com"
        ],
        "saml.myProvider": [
          "demo_user@gmail.com"
        ]
      },
      "sign_in_attributes": {
        "firstname": "John",
        "group": "test group",
        "role": "admin",
        "lastname": "Doe"
      },
      "sign_in_provider": "saml.myProvider",
      "tenant": "my_tenant_id"
    },
    "sub": "gZG0yELPypZElTmAT9I55prjHg63"
  }',
  "email": "securetoken.google.com/my_project_id/my_tenant_id:demo_user@gmail.com",
  "exp": 1553220470,
  "iat": 1553219870,
  "iss": "https://cloud.google.com/iap",
  "sub": "securetoken.google.com/my_project_id/my_tenant_id:gZG0yELPypZElTmAT9I55prjHg63"
}

您可以向您的应用添加类似于以下代码的逻辑,以限制具有有效角色的用户的访问:

const gcipClaims = JSON.parse(decodedIapJwtClaims.gcip);
if (gcipClaims &&
    gcipClaims.firebase &&
    gcipClaims.firebase.sign_in_attributes &&
    gcipClaims.firebase.sign_in_attribute.role === 'admin') {
  // Allow access to admin restricted resource.
} else {
  // Block access.
}

可以使用 gcipClaims.gcip.firebase.sign_in_attributes 嵌套声明访问 Identity Platform SAML 和 OIDC 提供商的其他用户属性。

IdP 声明的大小限制

用户使用 Identity Platform 登录后,额外的用户属性将传播到无状态的 Identity Platform ID 令牌载荷,这些载荷将安全地传递给 IAP。IAP 随后会发出自己的无状态不透明 Cookie,其中也包含相同的声明。IAP 将根据 Cookie 内容生成已签名的 JWT 标头。

因此,如果发起的会话包含大量声明,则它可能会超过允许的 Cookie 大小上限(在大多数浏览器中,此大小通常约为 4KB)。这将导致登录操作失败。

您应该确保仅将必要的声明传播到 IdP SAML 或 OIDC 属性中。另一种方法是使用屏蔽函数过滤掉授权检查不需要的声明。

const gcipCloudFunctions = require('gcip-cloud-functions');

const authFunctions = new gcipCloudFunctions.Auth().functions();

// This function runs before any sign-in operation.
exports.beforeSignIn = authFunctions.beforeSignInHandler((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'saml.my-provider') {
    // Get the original claims.
    const claims = context.credential.claims;
    // Define this function to filter out the unnecessary claims.
    claims.groups = keepNeededClaims(claims.groups);
    // Return only the needed claims. The claims will be propagated to the token
    // payload.
    return {
      sessionClaims: claims,
    };
  }
});