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

本页面介绍了如何使用签名的 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 会在请求通过 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 库来验证签名。

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
  • Cloud Run: /projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_NAME
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 声明。

如果您想使用 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

自动缓存公钥

IAP 会定期轮替其公钥。为确保您始终可以验证 IAP JWT,我们建议您缓存密钥,以免为每个请求都从公开网址提取密钥,并自动执行更新缓存密钥的流程。此方法对于在具有网络限制的环境(例如 VPC Service Controls 边界)中运行的应用特别有用。

VPC Service Controls 边界可以防止直接访问密钥的公开网址。通过将密钥缓存在 Cloud Storage 存储桶中,您的应用可以从 VPC-SC 边界内的位置提取密钥。

以下 Terraform 配置会将一个函数部署到 Cloud Run,该函数会从 https://www.gstatic.com/iap/verify/public_key-jwk 中提取最新的 IAP 公钥,并将它们存储在 Cloud Storage 存储桶中。Cloud Scheduler 作业每 12 小时触发一次此函数,以确保密钥是最新的。

此设置包括以下内容:

  • 已启用使用 Cloud Run 和存储/缓存密钥所需的 API Google Cloud
  • 用于存储提取的 IAP 公钥的 Cloud Storage 存储桶
  • 用于暂存 Cloud Run functions 源代码的 Cloud Storage 存储桶
  • 具有适当 IAM 权限的 Cloud Run 函数和服务账号和 Cloud Scheduler
  • 用于提取和存储密钥的 Python 函数
  • 一个每 12 小时触发一次函数的 Cloud Scheduler 作业

目录结构

├── function_source/
│   ├── main.py
│   └── requirements.txt
├── main.tf
├── outputs.tf
├── variables.tf
└── terraform.tfvars

function_source/main.py

import functions_framework
import requests
from google.cloud import storage
import os

# Environment variables to be set in the function configuration
BUCKET_NAME = os.environ.get("BUCKET_NAME")
OBJECT_NAME = os.environ.get("OBJECT_NAME", "iap_public_keys.jwk")
IAP_KEYS_URL = "https://www.gstatic.com/iap/verify/public_key-jwk"

@functions_framework.http
def update_iap_keys(request):
    """Fetches IAP public keys from the public URL and stores them in a Cloud Storage bucket."""
    if not BUCKET_NAME:
        print("Error: BUCKET_NAME environment variable not set.")
        return "BUCKET_NAME environment variable not set.", 500

    try:
        # Fetch the keys
        response = requests.get(IAP_KEYS_URL)
        response.raise_for_status()  # Raise an exception for bad status codes
        keys_content = response.text
        print(f"Successfully fetched keys from {IAP_KEYS_URL}")

        # Store in Cloud Storage
        storage_client = storage.Client()
        bucket = storage_client.bucket(BUCKET_NAME)
        blob = bucket.blob(OBJECT_NAME)

        blob.upload_from_string(keys_content, content_type='application/json')

        print(f"Successfully wrote IAP keys to gs://{BUCKET_NAME}/{OBJECT_NAME}")
        return f"Successfully updated {OBJECT_NAME} in bucket {BUCKET_NAME}", 200

    except requests.exceptions.RequestException as e:
        print(f"Error fetching keys from {IAP_KEYS_URL}: {e}")
        return f"Error fetching keys: {e}", 500
    except Exception as e:
        print(f"Error interacting with Cloud Storage: {e}")
        return f"Error interacting with Cloud Storage: {e}", 500

替换以下内容:

  • BUCKET_NAME:Cloud Storage 存储桶的名称
  • OBJECT_NAME:用于存储密钥的对象的名称

function_source/requirements.txt

functions-framework==3.*
requests
google-cloud-storage

variables.tf

variable "project_id" {
  description = "The Google Cloud project ID."
  type        = string
  default     = PROJECT_ID
}

variable "region" {
  description = "The Google Cloud region."
  type        = string
  default     = "REGION"
}

variable "iap_keys_bucket_name" {
  description = "The name of the Cloud Storage bucket to store IAP keys."
  type        = string
  default     = BUCKET_NAME"
}

variable "function_source_bucket_name" {
  description = "The name of the Cloud Storage bucket to store the function source code."
  type        = string
  default     = "BUCKET_NAME_FUNCTION"
}

替换以下内容:

  • PROJECT_ID:您的 Google Cloud 项目 ID
  • REGION:要在其中部署资源的区域,例如 us-central1
  • BUCKET_NAME:用于存储 IAP 密钥的 Cloud Storage 存储桶的名称
  • BUCKET_NAME_FUNCTION:用于存储 Cloud Run 函数源代码的 Cloud Storage 存储桶的名称

main.tf

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 4.50.0"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = ">= 4.50.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

provider "google-beta" {
  project = var.project_id
  region  = var.region
}

# Enable necessary APIs
resource "google_project_service" "services" {
  for_each = toset([
    "storage.googleapis.com",
    "cloudfunctions.googleapis.com",
    "run.googleapis.com", # Cloud Functions v2 uses Cloud Run
    "cloudscheduler.googleapis.com",
    "iamcredentials.googleapis.com",
    "cloudbuild.googleapis.com" # Needed for Cloud Functions deployment
  ])
  service            = each.key
  disable_on_destroy = false
}

# Cloud Storage Bucket to store the IAP public keys
resource "google_storage_bucket" "iap_keys_bucket" {
  name                        = var.iap_keys_bucket_name
  location                    = var.region
  uniform_bucket_level_access = true
  versioning {
    enabled = true
  }
  lifecycle {
    prevent_destroy = false # Set to true in production to prevent accidental deletion
  }
}

# Cloud Storage Bucket to store the Cloud Function source code
resource "google_storage_bucket" "function_source_bucket" {
  name                        = var.function_source_bucket_name
  location                    = var.region
  uniform_bucket_level_access = true
}

# Archive the function source code
data "archive_file" "function_source_zip" {
  type        = "zip"
  source_dir  = "${path.module}/function_source"
  output_path = "${path.module}/function_source.zip"
}

# Upload the zipped source code to the source bucket
resource "google_storage_bucket_object" "function_source_object" {
  name   = "function_source.zip"
  bucket = google_storage_bucket.function_source_bucket.name
  source = data.archive_file.function_source_zip.output_path
}

# Service Account for the Cloud Function
resource "google_service_account" "iap_key_updater_sa" {
  account_id   = "iap-key-updater"
  display_name = "IAP Key Updater Function SA"
}

# Grant the function's SA permission to write to the IAP keys bucket
resource "google_storage_bucket_iam_member" "keys_bucket_writer" {
  bucket = google_storage_bucket.iap_keys_bucket.name
  role   = "roles/storage.objectAdmin"
  member = "serviceAccount:${google_service_account.iap_key_updater_sa.email}"
}

# Cloud Function (v2)
resource "google_cloudfunctions2_function" "update_iap_keys_func" {
  provider = google-beta # CFv2 often has newer features in google-beta
  name     = "update-iap-keys-function"
  location = var.region

  build_config {
    runtime     = "python312"
    entry_point = "update_iap_keys"
    source {
      storage_source {
        bucket = google_storage_bucket.function_source_bucket.name
        object = google_storage_bucket_object.function_source_object.name
      }
    }
  }

  service_config {
    max_instance_count = 1
    available_memory   = "256M"
    timeout_seconds    = 60
    ingress_settings   = "ALLOW_ALL"
    service_account_email = google_service_account.iap_key_updater_sa.email
    environment_variables = {
      BUCKET_NAME = google_storage_bucket.iap_keys_bucket.name
      OBJECT_NAME = "iap_public_keys.jwk"
    }
  }

  depends_on = [
    google_project_service.services,
    google_storage_bucket_iam_member.keys_bucket_writer
  ]
}

# Service Account for the Cloud Scheduler job
resource "google_service_account" "iap_key_scheduler_sa" {
  account_id   = "iap-key-scheduler"
  display_name = "IAP Key Update Scheduler SA"
}

# Grant the Scheduler SA permission to invoke the Cloud Function
resource "google_cloudfunctions2_function_iam_member" "invoker" {
  provider       = google-beta
  project        = google_cloudfunctions2_function.update_iap_keys_func.project
  location       = google_cloudfunctions2_function.update_iap_keys_func.location
  cloud_function = google_cloudfunctions2_function.update_iap_keys_func.name
  role           = "roles/cloudfunctions.invoker"
  member         = "serviceAccount:${google_service_account.iap_key_scheduler_sa.email}"
}

# Cloud Scheduler Job
resource "google_cloud_scheduler_job" "iap_key_update_schedule" {
  name        = "iap-key-update-schedule"
  description = "Fetches IAP public keys and stores them in Cloud Storage every 12 hours"
  schedule    = "0 */12 * * *" # Every 12 hours
  time_zone   = "Etc/UTC"
  region      = var.region

  http_target {
    uri = google_cloudfunctions2_function.update_iap_keys_func.service_config[0].uri
    http_method = "POST"

    oidc_token {
      service_account_email = google_service_account.iap_key_scheduler_sa.email
    }
  }

  depends_on = [
    google_cloudfunctions2_function_iam_member.invoker,
    google_project_service.services
  ]
}

outputs.tf

output "iap_keys_bucket_url" {
  description = "The Cloud Storage bucket URL where IAP public keys are stored."
  value       = "gs://${google_storage_bucket.iap_keys_bucket.name}"
}

output "cloud_function_url" {
  description = "The URL of the Cloud Function endpoint that triggers key updates."
  value       = google_cloudfunctions2_function.update_iap_keys_func.service_config[0].uri
}

terraform.tfvars

创建 terraform.tfvars 文件以指定项目 ID,并根据需要自定义存储桶名称:

project_id = "your-gcp-project-id"
# Optional: Customize bucket names
# iap_keys_bucket_name = "custom-iap-keys-bucket"
# function_source_bucket_name = "custom-func-src-bucket"

使用 Terraform 进行部署

  1. 将文件保存在之前描述的目录结构中。
  2. 在终端中前往相应目录,然后初始化 Terraform:
    terraform init
  3. 规划更改:
    terraform plan
  4. 应用更改:
    terraform apply

这会部署基础架构。Cloud Scheduler 作业每 12 小时触发一次该函数,以提取 IAP 密钥并将其存储在 gs://BUCKET_NAME/iap_public_keys.jwk 中(默认情况下)。您的应用现在可以从该存储桶中获取密钥。

清理资源

如需移除由 Terraform 创建的资源,请运行以下命令:

gsutil rm -a gs://BUCKET_NAME/**

terraform destroy -auto-approve

BUCKET_NAME 替换为用于存储密钥的 Cloud Storage 存储桶。

外部身份的 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,
    };
  }
});