PHP でのユーザーの認証

App Engine などの Google Cloud マネージド プラットフォームで実行するアプリは、Identity-Aware Proxy(IAP)を使用することにより、ユーザー認証やセッション管理の手間を省いてアプリへのアクセスを制御できます。IAP は、アプリへのアクセスを制御できるだけでなく、新しい HTTP ヘッダーの形式でメールアドレスや永続的な識別子などの認証済みユーザーに関する情報をアプリに提供することもできます。

目標

  • IAP を使用することにより、App Engine アプリのユーザーに自分自身を認証するように要求する。

  • アプリでユーザーの ID にアクセスして、現在のユーザーの認証済みメールアドレスを表示する。

費用

このチュートリアルでは、課金対象である次の Google Cloud コンポーネントを使用します。

料金計算ツールを使うと、予想使用量に基づいて費用の見積もりを生成できます。 新しい Google Cloud ユーザーは無料トライアルをご利用いただける場合があります。

このチュートリアルを終了した後、作成したリソースを削除すると、それ以上の請求は発生しません。詳しくは、クリーンアップをご覧ください。

始める前に

  1. Google アカウントにログインします。

    Google アカウントをまだお持ちでない場合は、新しいアカウントを登録します。

  2. Cloud Console のプロジェクト セレクタページで、Cloud プロジェクトを選択または作成します。

    プロジェクト セレクタのページに移動

  3. Cloud SDK をインストールし、初期化します

背景

このチュートリアルでは、IAP を使用してユーザーを認証します。これは、いくつかのアプローチのうちの 1 つにすぎません。ユーザーのさまざまな認証方法の詳細については、認証のコンセプト セクションをご覧ください。

Hello user-email-address アプリ

このチュートリアル用のアプリは、最小の Hello world App Engine アプリで、「Hello world」の代わりに「Hello user-email-address」を表示する特別な機能を備えています。ここで、user-email-address は認証済みユーザーのメールアドレスです。

この機能は、IAP がアプリに渡す各ウェブ リクエストに追加する認証情報を調べることで可能になります。アプリに届くリクエストには 3 つの新しいリクエスト ヘッダーが追加されます。最初の 2 つのヘッダーは、ユーザーの識別に使用できる書式なしテキスト文字列です。3 つ目のヘッダーは、同じ情報を含む暗号署名付きオブジェクトです。

  • X-Goog-Authenticated-User-Email: ユーザーのメールアドレスでユーザーが識別されます。アプリで回避できる場合は、個人情報を保存しないようにしてください。このアプリはデータを保存しません。ユーザーにエコーバックするだけです。

  • X-Goog-Authenticated-User-Id: Google が割り当てたこのユーザー ID は、ユーザーに関する情報は表示しませんが、ログインしているユーザーが過去に表示されたユーザーと同じであることをアプリが認識できるようにします。

  • X-Goog-Iap-Jwt-Assertion: インターネット ウェブ リクエスト以外に、IAP を迂回してきた他のクラウドアプリからのウェブ リクエストを受け入れるように Google Cloud アプリを構成できます。アプリがこのように構成されている場合は、このようなリクエストのヘッダーが偽造されている可能性があります。上記のいずれかの書式なしテキスト ヘッダーを使用する代わりに、この暗号署名付きヘッダーを使用して、情報が Google から提供されたものであることを確認できます。ユーザーのメールアドレスと永続的なユーザー ID の両方をこの署名付きヘッダーの一部として使用できます。

アプリが、インターネット ウェブ リクエストのみを受け入れ、アプリに対する IAP サービスを無効にできないように構成されている場合は、一意のユーザー ID を取得するコードは 1 行で済みます。

$userId = getallheaders()['X-Goog-Authenticated-User-Id'] ?? null;
    

ただし、回復性の高いアプリは予期せぬ構成や環境問題などの障害を想定しておく必要があるため、代わりに、暗号署名付きヘッダーを使用して確認する関数を作成することをおすすめします。ヘッダーの署名は偽造できないため、検証時に、識別を返すのに使用できます。

ソースコードの作成

  1. テキスト エディタを使用して、index.php という名前のファイルを作成し、次のコードを貼り付けます。

    require_once __DIR__ . '/vendor/autoload.php';
    
        /**
         * Returns a dictionary of current Google public key certificates for
         * validating Google-signed JWTs.
         */
        function certs() : string
        {
            $client = new GuzzleHttp\Client();
            $response = $client->get(
                'https://www.gstatic.com/iap/verify/public_key-jwk'
            );
            return $response->getBody();
        }
    
        /**
         * Returns the audience value (the JWT 'aud' property) for the current
         * running instance. Since this involves a metadata lookup, the result is
         * cached when first requested for faster future responses.
         */
        function audience() : string
        {
            $metadata = new Google\Cloud\Core\Compute\Metadata();
            $projectNumber = $metadata->getNumericProjectId();
            $projectId = $metadata->getProjectId();
            $audience = sprintf('/projects/%s/apps/%s', $projectNumber, $projectId);
            return $audience;
        }
    
        /**
         * Checks that the JWT assertion is valid (properly signed, for the
         * correct audience) and if so, returns strings for the requesting user's
         * email and a persistent user ID. If not valid, returns null for each field.
         *
         * @param string $assertion The JWT string to assert.
         * @param string $certs The certificates to use for the assertion validation.
         * @param string $audience The required audience of the JWT.
         * @return array containing [$email, $id], or [null, null] on failed validation.
         */
        function validate_assertion(string $assertion, string $certs, string $audience) : array
        {
            $jwkset = new SimpleJWT\Keys\KeySet();
            $jwkset->load($certs);
            try {
                $info = SimpleJWT\JWT::decode(
                    $assertion,
                    $jwkset,
                    'ES256'
                );
                if ($info->getClaim('aud') != $audience) {
                    throw new Exception('Audience did not match');
                }
                return [$info->getClaim('email'), $info->getClaim('sub')];
            } catch (Exception $e) {
                printf('Failed to validate assertion: %s', $e->getMessage());
                return [null, null];
            }
        }
    
        /**
         * This is an example of a front controller for a flat file PHP site. Using a
         * static list provides security against URL injection by default.
         */
        switch (@parse_url($_SERVER['REQUEST_URI'])['path']) {
            case '/':
                $assertion = getallheaders()['X-Goog-Iap-Jwt-Assertion'] ?? '';
                list($email, $id) = validate_assertion($assertion, certs(), audience());
                if ($email) {
                    printf("<h1>Hello %s</h1>", $email);
                }
                break;
            case '': break; // Nothing to do, we're running our tests
            default:
                http_response_code(404);
                exit('Not Found');
        }

    この index.php ファイルの詳細については、このチュートリアルの後半のコードについてセクションで説明します。

  2. composer.json という名前の別のファイルを作成し、次のコードを貼り付けます。

    {
            "require": {
                "php": ">=7.1",
                "google/cloud-core": "^1.32",
                "kelvinmo/simplejwt": "^0.2.5",
                "ralouphie/getallheaders": "^3.0"
            }
        }
        

    composer.json ファイルでは、App Engine で読み込む必要のある PHP ライブラリが一覧表示されます。

    • firebase/php-jwt は、JWT のチェックおよびデコード機能を提供します。

    • guzzle/http は、ウェブサイトからデータを取得するための HTTP クライアントです。

  3. app.yaml という名前のファイルを作成し、次のテキストを入力します。

    runtime: php72
        

    app.yaml ファイルは、コードが必要とする言語環境を App Engine に指示します。

コードについて

このセクションでは、index.php 内のコードの機能について説明します。アプリを実行するだけの場合は、アプリのデプロイ セクションまでスキップできます。

次のコードが index.php ファイルに格納されています。ホームページへの HTTP GET リクエストがアプリによって受信された場合は、/ のスイッチケースが呼び出されます。

/**
     * This is an example of a front controller for a flat file PHP site. Using a
     * static list provides security against URL injection by default.
     */
    switch (@parse_url($_SERVER['REQUEST_URI'])['path']) {
        case '/':
            $assertion = getallheaders()['X-Goog-Iap-Jwt-Assertion'] ?? '';
            list($email, $id) = validate_assertion($assertion, certs(), audience());
            if ($email) {
                printf("<h1>Hello %s</h1>", $email);
            }
            break;
        case '': break; // Nothing to do, we're running our tests
        default:
            http_response_code(404);
            exit('Not Found');
    }

say_hello 関数は、IAP が受信リクエストから追加した JWT アサーション ヘッダー値を取得し、その暗号署名付き値を検証する関数を呼び出します。返される最初の値(メール)は、作成されて返される最小ウェブページで使用されます。

/**
     * Checks that the JWT assertion is valid (properly signed, for the
     * correct audience) and if so, returns strings for the requesting user's
     * email and a persistent user ID. If not valid, returns null for each field.
     *
     * @param string $assertion The JWT string to assert.
     * @param string $certs The certificates to use for the assertion validation.
     * @param string $audience The required audience of the JWT.
     * @return array containing [$email, $id], or [null, null] on failed validation.
     */
    function validate_assertion(string $assertion, string $certs, string $audience) : array
    {
        $jwkset = new SimpleJWT\Keys\KeySet();
        $jwkset->load($certs);
        try {
            $info = SimpleJWT\JWT::decode(
                $assertion,
                $jwkset,
                'ES256'
            );
            if ($info->getClaim('aud') != $audience) {
                throw new Exception('Audience did not match');
            }
            return [$info->getClaim('email'), $info->getClaim('sub')];
        } catch (Exception $e) {
            printf('Failed to validate assertion: %s', $e->getMessage());
            return [null, null];
        }
    }

validate_assertion 関数は、サードパーティ firebase/jwt ライブラリからの Firebase\JWT\JWT::decode 関数を使用して、アサーションが適切に署名されていることを確認し、アサーションからペイロード情報を抽出します。この情報は、認証済みユーザーのメールアドレスと永続的な一意の ID です。アサーションをデコードできない場合は、この関数はそれらの値ごとに None を返し、エラーを記録するためのメッセージを出力します。

JWT アサーションを検証するには、アサーションに署名した機関(この場合は Google)の公開鍵証明書と、アサーションの対象オーディエンスを把握している必要があります。App Engine アプリの場合、オーディエンスは Google Cloud のプロジェクト識別情報を含む文字列です。この関数は、それらの証明書とその前の関数からのオーディエンス文字列を取得します。

/**
     * Returns the audience value (the JWT 'aud' property) for the current
     * running instance. Since this involves a metadata lookup, the result is
     * cached when first requested for faster future responses.
     */
    function audience() : string
    {
        $metadata = new Google\Cloud\Core\Compute\Metadata();
        $projectNumber = $metadata->getNumericProjectId();
        $projectId = $metadata->getProjectId();
        $audience = sprintf('/projects/%s/apps/%s', $projectNumber, $projectId);
        return $audience;
    }

自分で Google Cloud プロジェクトの数値 ID と名前を検索して、ソースコードに含めることもできますが、代わりに、audience 関数がすべての App Engine アプリで使用可能になっている標準のメタデータ サービスを照会することによってこの処理を行います。

App Engine のメタデータ サービス(および他の Google Cloud コンピューティング サービスの類似のメタデータ サービス)は、ウェブサイトのように見え、標準のウェブクエリによって照会されます。ただし、実際には、外部サイトではなく、実行中のアプリに関する要求された情報を返す内部機能であるため、https リクエストの代わりに http リクエストを使用しても安全です。これは、JWT アサーションの対象オーディエンスを定義するために必要な現在の Google Cloud 識別子を取得するために使用されます。

/**
     * Returns a dictionary of current Google public key certificates for
     * validating Google-signed JWTs.
     */
    function certs() : string
    {
        $client = new GuzzleHttp\Client();
        $response = $client->get(
            'https://www.gstatic.com/iap/verify/public_key-jwk'
        );
        return $response->getBody();
    }

デジタル署名を検証するには、署名者の公開鍵証明書が必要です。Google では、現在使用されているすべての公開鍵証明書を返すウェブサイトを提供しています。これらの結果は、同じアプリ インスタンス内で再び必要になる場合に備えてキャッシュに保存されます。

アプリのデプロイ

これで、アプリをデプロイして、次いで Cloud IAP でユーザーが認証しないとユーザーがアプリにアクセスできないようにできます。

  1. ターミナル ウィンドウで、app.yaml ファイルが格納されたディレクトリに移動して、アプリを App Engine にデプロイします。

    gcloud app deploy
        
  2. プロンプトが表示されたら、最寄のリージョンを選択します。

  3. デプロイ操作を続行するかどうかを尋ねられたら、Y と入力します。

    数分以内に、アプリがインターネット上に公開されます。

  4. 次のようにしてアプリを表示します。

    gcloud app browse
        

    出力で、web-site-url(アプリのウェブアドレス)をコピーします。

  5. ブラウザ ウィンドウで、web-site-url を貼り付けてアプリを開きます。

    IAP をまだ使用していないのでメールが表示されないため、ユーザー情報がアプリに送信されません。

IAP を有効にする

これで App Engine インスタンスが存在するようになり、IAP を使用して保護できます。

  1. Google Cloud Console で、[Identity-Aware Proxy] ページに移動します。

    Identity-Aware Proxy ページに移動

  2. 今回はこのプロジェクト用の認証オプションを初めて有効にしたため、IAP を使用するには OAuth 同意画面を構成する必要があることを示すメッセージが表示されます。

    [同意画面を構成] をクリックします。

  3. [認証情報] ページの [OAuth 同意画面] タブで、次のフィールドに値を入力します。

    • [アプリケーション名] フィールドに、IAP Example と入力します。

    • [サポートメール] フィールドに、メールアドレスを入力します。

    • [承認済みドメイン] フィールドに、アプリの URL のホスト名部分(iap-example-999999.uc.r.appspot.com など)を入力します。フィールドにホスト名を入力したら、Enter キーを押します。

    • [アプリケーション ホームページ リンク] フィールドに、アプリの URL(https://iap-example-999999.uc.r.appspot.com/ など)を入力します。

    • [Application privacy policy line] フィールドでは、テスト用にホームページ リンクと同じ URL を使用します。

  4. [保存] をクリックします。認証情報の作成が要求されたら、ウィンドウを閉じます。

  5. Cloud Console で、[Identity-Aware Proxy] ページに移動します。

    Identity-Aware Proxy ページに移動

  6. ページを更新するには、[更新] をクリックします。ページに、保護可能なリソースのリストが表示されます。

  7. [IAP] 列でクリックしてアプリの IAP をオンにします。

  8. ブラウザで、再度 web-site-url にアクセスします。

  9. ウェブページの代わりに、自分を認証するためのログイン画面が表示されます。ログインしようとすると、アプリにアクセス可能なユーザーのリストが IAP にないため、アクセスが拒否されます。

アプリに承認済みユーザーを追加する

  1. Cloud Console で、[Identity-Aware Proxy] ページに移動します。

    Identity-Aware Proxy ページに移動

  2. App Engine アプリのチェックボックスをオンにしてから、[メンバーを追加] をクリックします。

  3. allAuthenticatedUsers」と入力してから、[Cloud IAP/IAP で保護されたウェブアプリ ユーザー] 役割を選択します。

  4. [保存] をクリックします。

これで、Google で認証可能なすべてのユーザーがアプリにアクセスできます。必要に応じて、1 人または複数のユーザーやグループをメンバーとして追加するだけで、さらにアクセスを制限できます。

  • 任意の Gmail または G Suite メールアドレス

  • Google グループ メールアドレス

  • G Suite ドメイン名

アプリにアクセスする

  1. ブラウザで、web-site-url にアクセスします。

  2. ページを更新するには、[更新] 更新をクリックします。

  3. ログイン画面で、Google 認証情報を使用してログインします。

    ページに、メールアドレスが記載された「Hello user-email-address」ページが表示されます。

    以前と同じページが表示される場合は、IAP を有効にしたブラウザで新しいリクエストが完全に更新されない問題が発生している可能性があります。すべてのブラウザ ウィンドウを閉じてから、もう一度開いて、やり直してください。

認証のコンセプト

アプリが、そのユーザーを認証し、承認されたユーザーのみにアクセスを制限可能な方法がいくつかあります。次のセクションで、一般的な認証方法をアプリの負荷が高い順に一覧表示します。

オプション メリット デメリット
アプリ認証
  • インターネット接続の有無にかかわらず、どのプラットフォームでもアプリを実行できる
  • 他のサービスを使用して認証を管理する必要がない
  • アプリはユーザー認証情報を安全に管理して、漏洩しないよう保護する必要がある
  • アプリはログインしているユーザーのセッション データを維持する必要がある
  • アプリはユーザー登録、パスワード変更、パスワード復元を提供する必要がある
OAuth2
  • デベロッパー ワークステーションなど、インターネットに接続されたあらゆるプラットフォームでアプリを実行できる
  • アプリはユーザー登録、パスワード変更、パスワード復元の各機能を必要としない
  • ユーザー情報漏洩のリスクが他のサービスに委任される
  • 新しいログイン セキュリティ対策がアプリの外部で処理される
  • ユーザーが ID サービスに登録する必要がある
  • アプリはログインしているユーザーのセッション データを維持する必要がある
IAP
  • アプリはユーザー、認証、セッション状態を管理するためのコードを必要としない
  • アプリは侵害される可能性のあるユーザー認証情報を持たない
  • アプリはサービスでサポートされているプラットフォームでしか実行できない。 具体的には、App Engine などの IAP をサポートする特定の Google Cloud サービスです。

アプリ管理認証

この方法では、アプリがユーザー認証のすべての側面を独自に管理します。アプリは、ユーザー認証情報の独自のデータベースを維持してユーザー セッションを管理し、ユーザー アカウントとパスワードの管理、ユーザー認証情報のチェック、認証されたログインごとのユーザー セッションの発行、チェック、更新を行う必要があります。次の図は、アプリ管理認証方法を示しています。

アプリケーション管理フロー

図に示すように、ユーザーがログインすると、アプリがそのユーザーのセッションに関する情報を作成して維持します。ユーザーがアプリにリクエストを発行する場合は、そのリクエストにアプリが検証するセッション情報が含まれている必要があります。

このアプローチの主なメリットは、自己完結型であることと、アプリの制御下で行われることです。アプリをインターネット上で使用可能にする必要さえありません。主なデメリットは、アプリがすべてのアカウント管理機能を提供し、すべての機密性の高い認証情報データを保護する必要があることです。

OAuth2 を使用した外部認証

アプリ内ですべてを処理する代わりに、Google などの外部の ID サービスを使用して、すべてのアカウント情報と機能を処理し、機密性の高い認証情報を保護することをおすすめします。ユーザーがアプリにログインしようとすると、リクエストが ID サービスにリダイレクトされ、そこでユーザーが認証されてから、必要な認証情報と一緒にリクエストがアプリに返されます。詳細については、エンドユーザーとして認証をご覧ください。

次の図は、OAuth2 方式を使用した外部認証を示しています。

OAuth2 のフロー

図のフローは、ユーザーがアプリにアクセスするためのリクエストを送信するところから始まります。直接応答する代わりに、アプリは、ユーザーのブラウザを Google の Identity Platform にリダイレクトします。そこで、Google にログインするためのページが表示されます。ログインに成功すると、ユーザーのブラウザがアプリに戻されます。このリクエストには、認証されたユーザーに関してアプリが検索可能な情報が含まれており、アプリはユーザーに応答できます。

この方法には、アプリにとっての多くのメリットがあります。アプリがすべてのアカウント管理機能とリスクを外部サービスに委任することで、アプリを変更せずにログインとアカウント セキュリティを向上させることができます。ただし、上の図に示すように、この方法を使用するには、アプリからインターネットにアクセスできる必要があります。また、アプリには、ユーザーの認証後にセッションを管理する責任があります。

Identity-Aware Proxy

このチュートリアルで扱う 3 つ目のアプローチは、IAP を使用して、認証とアプリに対するなんらかの変更を伴うセッション管理をすべて処理する方法です。IAP は、アプリへのすべてのウェブ リクエストを傍受して、認証されていないリクエストをブロックし、それ以外のリクエストはリクエストごとにユーザー ID データを追加してアプリに渡します。

リクエストの処理を次の図に示します。

IAP のフロー

ユーザーからの要求は、IAP によって傍受され、認証されていない要求がブロックされます。認証されたユーザーが許可されたユーザーのリストに含まれている場合は、認証されたリクエストがアプリに渡されます。IAP を介して渡されたリクエストには、要求を行ったユーザーを識別するヘッダーが追加されます。

アプリは、ユーザー アカウントやセッション情報を処理する必要がなくなります。ユーザーの一意の識別子を知る必要があるオペレーションでは、受信ウェブ リクエストから直接取得できます。ただし、App Engine やロードバランサなど、IAP をサポートするコンピューティング サービスでのみ使用できます。ローカル開発マシンで IAP を使用することはできません。

クリーンアップ

このチュートリアルで使用したリソースについて、Google Cloud Platform アカウントに課金されないようにする手順は次のとおりです。

課金を停止する最も簡単な方法は、チュートリアル用に作成したプロジェクトを削除することです。

プロジェクトを削除するには、次のようにします。

  1. Cloud Console で [リソースの管理] ページに移動します。

    [リソースの管理] ページに移動

  2. プロジェクト リストで、削除するプロジェクトを選択し、[削除] をクリックします。
  3. ダイアログでプロジェクト ID を入力し、[シャットダウン] をクリックしてプロジェクトを削除します。

次のステップ