서명된 헤더로 앱 보안 강화

이 페이지에서는 서명된 IAP 헤더로 앱을 보호하는 방법에 대해 설명합니다. IAP(Identity-Aware Proxy)가 구성되면 JWT(JSON 웹 토큰)를 사용하여 앱에 대한 요청이 승인되었는지 확인합니다. 이러한 방식은 다음 종류의 위험으로부터 앱을 보호합니다.

  • 실수로 사용 중지된 IAP
  • 잘못 구성된 방화벽
  • 프로젝트 내부에서의 액세스

앱을 올바르게 보호하려면 모든 앱 유형에 서명된 헤더를 사용해야 합니다.

또는 App Engine 표준 환경 앱이 있는 경우 Users API를 사용할 수 있습니다.

Compute Engine 및 GKE 상태 확인에는 JWT 헤더가 포함되지 않으며 IAP는 상태 확인을 처리하지 않습니다. 상태 확인이 액세스 오류를 반환할 경우, Cloud Console에서 올바르게 구성되었는지 그리고 JWT 헤더 검증에서 상태 확인 경로가 허용 목록에 포함되었는지 확인합니다. 자세한 내용은 상태 확인 예외 만들기를 참조하세요.

시작하기 전에

서명된 헤더로 앱을 보호하기 위해서는 다음이 필요합니다.

IAP 헤더로 앱 보호

IAP JWT로 앱을 보호하기 위해서는 JWT의 헤더, 페이로드, 서명을 확인합니다. JWT는 HTTP 요청 헤더 x-goog-iap-jwt-assertion에 있습니다. 공격자가 IAP를 우회할 경우, 공격자는 IAP 비서명 ID 헤더인 x-goog-authenticated-user-{email,id}를 위조할 수 있습니다. IAP JWT는 보다 안전한 대안을 제공합니다.

서명된 헤더는 다른 사람이 IAP를 우회할 경우를 대비하여 보조 보안 수단을 제공합니다. IAP가 사용 설정되면 요청이 IAP 제공 인프라를 통과할 때 클라이언트가 제공한 x-goog-* 헤더가 제거됩니다.

JWT 헤더 확인

JWT 헤더가 다음 제약조건을 따르는지 확인합니다.

JWT 헤더 클레임
alg 알고리즘 ES256
kid 키 ID https://www.gstatic.com/iap/verify/public_keyhttps://www.gstatic.com/iap/verify/public_key-jwk의 두 가지 형식으로 사용 가능한 IAP 키 파일에 나열된 공개 키 중 하나와 일치해야 합니다.

토큰의 kid 클레임에 해당하는 비공개 키로 JWT에 서명했는지 확인합니다. 이를 위해서는 먼저 다음 두 위치 중 하나에서 공개 키를 가져옵니다.

  • https://www.gstatic.com/iap/verify/public_key. 이 URL에는 kid 클레임을 공개 키 값에 매핑하는 JSON 딕셔너리가 포함되어 있습니다.
  • https://www.gstatic.com/iap/verify/public_key-jwk. 이 URL에는 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 키 아래에 문자열 배열로 저장됩니다.

Cloud Console에 액세스하여 위에서 언급한 aud 문자열의 값을 가져오거나 gcloud 명령줄 도구를 사용할 수 있습니다.

Cloud Console에서 aud 문자열 값을 얻으려면 프로젝트의 IAP(Identity-Aware Proxy) 설정으로 이동하여 부하 분산기 리소스 옆에 있는 더보기를 클릭한 후 서명된 헤더 JWT 대상을 선택하세요. 표시되는 서명된 헤더 JWT 대화상자에 선택된 리소스의 aud 클레임이 표시됩니다.

서명된 헤더 JWT 대상 옵션이 포함된 더보기 메뉴

Cloud SDK gcloud 명령줄 도구를 사용하여 aud 문자열 값을 얻으려면 프로젝트 ID를 알아야 합니다. Cloud Console 프로젝트 정보 카드에서 프로젝트 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를 검색합니다. ID 토큰의 페이로드에는 다음 사용자 정보가 포함됩니다.

ID 토큰 페이로드 사용자 ID
sub 제목 사용자에 대한 고유하고 안정적인 식별자입니다. x-goog-authenticated-user-id 헤더 대신 이 값을 사용하세요.
email 사용자 이메일 사용자 이메일 주소입니다.
  • x-goog-authenticated-user-email 헤더 대신 이 값을 사용하세요.
  • 해당 헤더 및 sub 클레임과 달리 이 값에는 네임스페이스 프리픽스가 없습니다.

다음은 서명된 IAP 헤더로 앱을 보호하기 위한 샘플 코드입니다.

C#


using Google.Apis.Auth;
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 }
        };

        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: %v", 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: %v", err)
	}

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

	return nil
}

자바


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 $iap_jwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $cloud_project_number 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 $cloud_project Your Google Cloud Project ID.
 *
 * @return (user_id, user_email).
 */
function validate_jwt_from_app_engine($iap_jwt, $cloud_project_number, $cloud_project_id)
{
    $expected_audience = sprintf(
        '/projects/%s/apps/%s',
        $cloud_project_number,
        $cloud_project_id
    );
    return validate_jwt($iap_jwt, $expected_audience);
}

/**
 * Validate a JWT passed to your Compute / Container Engine app by Identity-Aware Proxy.
 *
 * @param string $iap_jwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $cloud_project_number 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 $backend_service_id 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.
 *
 * @return (user_id, user_email).
 */
function validate_jwt_from_compute_engine($iap_jwt, $cloud_project_number, $backend_service_id)
{
    $expected_audience = sprintf(
        '/projects/%s/global/backendServices/%s',
        $cloud_project_number,
        $backend_service_id
    );
    return validate_jwt($iap_jwt, $expected_audience);
}

function validate_jwt($iap_jwt, $expected_audience)
{
    // Validate the signature using the IAP cert URL.
    $token = new AccessToken();
    $jwt = $token->verify($iap_jwt, [
        'certsLocation' => AccessToken::IAP_CERT_URL
    ]);

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

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

    // Return the user identity (subject and user email) if JWT verification is successful.
    return array('sub' => $jwt['sub'], 'email' => $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, '**ERROR: JWT validation error {}**'.format(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 특수 URL을 사용하여 앱으로 이동하면 IAP에 잘못된 JWT가 포함됩니다. 이를 사용하여 JWT 검증 로직이 다양한 실패 사례를 모두 처리할 수 있는지 확인하고 잘못된 JWT를 받을 때의 앱 동작을 확인합니다.

상태 확인 예외 만들기

앞에서 설명한 것처럼 Compute Engine 및 GKE 상태 확인은 JWT 헤더를 사용하지 않으며 IAP는 상태 확인을 처리하지 않습니다. 상태 확인을 구성할 필요가 없고, 앱이 상태 확인을 허용하도록 구성할 필요가 없습니다.

상태 확인 구성

상태 확인 경로를 아직 설정하지 않은 경우 Cloud Console을 사용하여 상태 확인에 대한 중요하지 않은 경로를 설정하세요. 이 경로는 다른 리소스에 의해 공유되지 않도록 하세요.

  1. Cloud Console 상태 확인 페이지로 이동합니다.
    상태 확인 페이지로 이동
  2. 앱에 사용 중인 상태 확인을 클릭한 후 수정을 클릭합니다.
  3. 요청 경로 아래에서 중요하지 않은 경로 이름을 추가합니다. 이는 Google Cloud가 상태 확인 요청을 보낼 때 사용하는 URL 경로를 지정합니다. 생략하면 상태 확인 요청이 /로 전송됩니다.
  4. 저장을 클릭합니다.

JWT 검증 구성

JWT 검증 루틴을 호출하는 코드에서 상태 확인 요청 경로에 대해 200 HTTP 상태를 제공하는 조건을 추가합니다. 예를 들면 다음과 같습니다.

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

외부 ID를 위한 JWT

외부 ID와 함께 IAP를 사용하는 경우 IAP는 Google ID와 마찬가지로 인증된 모든 요청에 대해 서명된 JWT를 계속 발행합니다. 하지만 몇 가지 차이점이 있습니다.

제공업체 정보

외부 ID를 사용하는 경우 JWT 페이로드에는 gcip라는 클레임이 포함됩니다. 이 클레임에는 이메일 및 사진 URL과 같은 사용자에 대한 정보와 추가 제공업체별 속성이 포함됩니다.

다음은 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은 외부 ID와 함께 사용할 수 없지만 대신 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 제공업체의 추가 사용자 속성에 액세스할 수 있습니다.