使用已簽署的標頭確保應用程式安全無虞

本頁面說明如何使用已簽署的 IAP 標頭保護應用程式。設定完成後,Identity-Aware Proxy (IAP) 會使用 JSON Web Token (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}。IAP JWT 可提供更安全的替代方案。

如果有人略過 IAP,已簽署標頭會提供第二重安全防護。請注意,啟用 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 到期時間 必須是未來的日期。時間以秒為單位,從世界標準時間指定期間開始計算。允許 30 秒的偏移。 憑證的最大生命週期為 10 分鐘 + 2 * 偏移。
iat 核發時間 必須是過去的時間。時間以秒為單位,從世界標準時間指定期間開始計算。允許 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 物件中。請注意,傳送至其他機構的要求可能沒有檢視裝置資料的權限。

您可以存取Google Cloud 主控台,或使用 gcloud 指令列工具,取得上述 aud 字串的值。

如要從 Google Cloud 主控台取得 aud 字串值,請前往專案的 Identity-Aware Proxy 設定,按一下負載平衡器資源旁邊的「More」,然後選取「Signed Header JWT Audience」。這時出現的「Signed Header JWT」(已簽署標頭的 JWT) 對話方塊會顯示已選取資源的 aud 憑證附加資訊。

含已簽署標頭的 JWT 目標對象選項的溢位選單

如果您要使用 gcloud CLI gcloud 指令列工具取得 aud 字串值,則需要知道專案 ID。您可以在 Google Cloud 主控台的「Project info」資訊卡中找到專案 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 主旨 使用者的專屬固定 ID。請使用此值,而非 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 控制台的「Health checks」頁面。
    前往「Health checks」(健康狀態檢查) 頁面
  2. 按一下您要針對應用程式使用的健康狀態檢查,然後按一下 [Edit] (編輯)
  3. 在「Request path」(要求路徑) 下,新增非機密路徑名稱。此會指定 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 大小,在大多數瀏覽器中,這個大小通常為 4 KB。這會導致登入作業失敗。

請務必確保 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,
    };
  }
});