서명된 헤더로 앱 보안 강화

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

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

적절한 앱 보호를 위해서는 App Engine 가변형 환경, Compute Engine, GKE 애플리케이션에 대해 서명된 헤더를 사용해야 합니다. App Engine 표준 환경 애플리케이션에서는 서명된 헤더가 지원되지 않습니다. 대신 이러한 애플리케이션은 사용자 ID 가져오기에 설명된 방식을 따라야 합니다.

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

시작하기 전에

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

Cloud IAP 헤더로 앱 보호

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

서명된 헤더는 다른 사람이 Cloud IAP를 우회할 경우를 대비하여 보조 보안 수단을 제공합니다. Cloud IAP가 사용 설정된 경우, 요청이 Cloud 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로 제공되는 Cloud 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 형식의 Cloud 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 키 아래에 문자열 배열로 저장됩니다.

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

GCP Console에서 aud 문자열 값을 가져오려면 프로젝트의 Identity-Aware Proxy 설정으로 이동하고, 부하 분산기 리소스 옆에서 더보기를 클릭한 후 서명된 헤더 JWT 대상을 선택합니다. 표시된 서명된 헤더 JWT 대화상자에는 선택된 리소스의 aud 클레임이 표시됩니다.

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

Cloud SDK gcloud 명령줄 도구를 사용해서 aud 문자열 값을 가져오려면 프로젝트 ID를 알아야 합니다. GCP 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 클레임과 달리, 이 값은 네임스페이스 프리픽스를 갖지 않습니다.

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

Python

import jwt
import requests

def validate_iap_jwt_from_app_engine(iap_jwt, cloud_project_number,
                                     cloud_project_id):
    """Validate a JWT passed to your App Engine app by Identity-Aware Proxy.

    Args:
      iap_jwt: The contents of the X-Goog-IAP-JWT-Assertion header.
      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.
      cloud_project_id: The project *ID* for your Google Cloud project.

    Returns:
      (user_id, user_email, error_str).
    """
    expected_audience = '/projects/{}/apps/{}'.format(
        cloud_project_number, cloud_project_id)
    return _validate_iap_jwt(iap_jwt, expected_audience)

def validate_iap_jwt_from_compute_engine(iap_jwt, cloud_project_number,
                                         backend_service_id):
    """Validate an IAP JWT for your (Compute|Container) Engine service.

    Args:
      iap_jwt: The contents of the X-Goog-IAP-JWT-Assertion header.
      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.
      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.

    Returns:
      (user_id, user_email, error_str).
    """
    expected_audience = '/projects/{}/global/backendServices/{}'.format(
        cloud_project_number, backend_service_id)
    return _validate_iap_jwt(iap_jwt, expected_audience)

def _validate_iap_jwt(iap_jwt, expected_audience):
    try:
        key_id = jwt.get_unverified_header(iap_jwt).get('kid')
        if not key_id:
            return (None, None, '**ERROR: no key ID**')
        key = get_iap_key(key_id)
        decoded_jwt = jwt.decode(
            iap_jwt, key,
            algorithms=['ES256'],
            audience=expected_audience)
        return (decoded_jwt['sub'], decoded_jwt['email'], '')
    except (jwt.exceptions.InvalidTokenError,
            requests.exceptions.RequestException) as e:
        return (None, None, '**ERROR: JWT validation error {}**'.format(e))

def get_iap_key(key_id):
    """Retrieves a public key from the list published by Identity-Aware Proxy,
    re-fetching the key file if necessary.
    """
    key_cache = get_iap_key.key_cache
    key = key_cache.get(key_id)
    if not key:
        # Re-fetch the key file.
        resp = requests.get(
            'https://www.gstatic.com/iap/verify/public_key')
        if resp.status_code != 200:
            raise Exception(
                'Unable to fetch IAP keys: {} / {} / {}'.format(
                    resp.status_code, resp.headers, resp.text))
        key_cache = resp.json()
        get_iap_key.key_cache = key_cache
        key = key_cache.get(key_id)
        if not key:
            raise Exception('Key {!r} not found'.format(key_id))
    return key

# Used to cache the Identity-Aware Proxy public keys.  This code only
# refetches the file when a JWT is signed with a key not present in
# this cache.
get_iap_key.key_cache = {}

자바

import com.google.api.client.http.HttpRequest;
import com.google.common.base.Preconditions;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.ECDSAVerifier;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.net.URL;
import java.security.interfaces.ECPublicKey;
import java.time.Clock;
import java.time.Instant;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

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

  private static final String PUBLIC_KEY_VERIFICATION_URL =
      "https://www.gstatic.com/iap/verify/public_key-jwk";

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

  // using a simple cache with no eviction for this sample
  private final Map<String, JWK> keyCache = new HashMap<>();

  private static Clock clock = Clock.systemUTC();

  private ECPublicKey getKey(String kid, String alg) throws Exception {
    JWK jwk = keyCache.get(kid);
    if (jwk == null) {
      // update cache loading jwk public key data from url
      JWKSet jwkSet = JWKSet.load(new URL(PUBLIC_KEY_VERIFICATION_URL));
      for (JWK key : jwkSet.getKeys()) {
        keyCache.put(key.getKeyID(), key);
      }
      jwk = keyCache.get(kid);
    }
    // confirm that algorithm matches
    if (jwk != null && jwk.getAlgorithm().getName().equals(alg)) {
      return ECKey.parse(jwk.toJSONString()).toECPublicKey();
    }
    return null;
  }

  // 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) throws Exception {

    // parse signed token into header / claims
    SignedJWT signedJwt = SignedJWT.parse(jwtToken);
    JWSHeader jwsHeader = signedJwt.getHeader();

    // header must have algorithm("alg") and "kid"
    Preconditions.checkNotNull(jwsHeader.getAlgorithm());
    Preconditions.checkNotNull(jwsHeader.getKeyID());

    JWTClaimsSet claims = signedJwt.getJWTClaimsSet();

    // claims must have audience, issuer
    Preconditions.checkArgument(claims.getAudience().contains(expectedAudience));
    Preconditions.checkArgument(claims.getIssuer().equals(IAP_ISSUER_URL));

    // claim must have issued at time in the past
    Date currentTime = Date.from(Instant.now(clock));
    Preconditions.checkArgument(claims.getIssueTime().before(currentTime));
    // claim must have expiration time in the future
    Preconditions.checkArgument(claims.getExpirationTime().after(currentTime));

    // must have subject, email
    Preconditions.checkNotNull(claims.getSubject());
    Preconditions.checkNotNull(claims.getClaim("email"));

    // verify using public key : lookup with key id, algorithm name provided
    ECPublicKey publicKey = getKey(jwsHeader.getKeyID(), jwsHeader.getAlgorithm().getName());

    Preconditions.checkNotNull(publicKey);
    JWSVerifier jwsVerifier = new ECDSAVerifier(publicKey);
    return signedJwt.verify(jwsVerifier);
  }
}

PHP

namespace Google\Cloud\Samples\Iap;

# Imports OAuth Guzzle HTTP libraries.
use Jose\Factory\JWKFactory;
use Jose\Loader;

/**
 * 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)
{
    // Create a JWK Key Set from the gstatic URL
    $jwk_set = JWKFactory::createFromJKU('https://www.gstatic.com/iap/verify/public_key-jwk');

    // Validate the signature using the key set and ES256 algorithm.
    $loader = new Loader();
    $jws = $loader->loadAndVerifySignatureUsingKeySet(
        $iap_jwt,
        $jwk_set,
        ['ES256']
    );

    // Validate token by checking issuer and audience fields.
    assert($jws->getClaim('iss') == 'https://cloud.google.com/iap');
    assert($jws->getClaim('aud') == $expected_audience);

    // Return the user identity (subject and user email) if JWT verification is successful.
    return array('sub' => $jws->getClaim('sub'), 'email' => $jws->getClaim('email'));
}

상태 확인 예외 만들기

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

상태 확인 구성

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

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

JWT 검증 구성

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

if HttpRequest.path_info = '/HEALTH_CHECK_REQUEST_PATH'
  return HttpResponse(status=200)
else
  VALIDATION_FUNCTION
이 페이지가 도움이 되었나요? 평가를 부탁드립니다.

다음에 대한 의견 보내기...

Identity-Aware Proxy 문서