既存のWebアプリで「Amazon Cognito」認証を利用する(その2:PHPのサンプルプログラムの実装)

2023-03-27Amazon,AWS

はじめに

既存の Web アプリで「Amazon Cognito」認証を利用する(その1:処理フローの整理) | ゲンゾウ用ポストイット で「Cognito」の認証の流れを整理しました。

ここからは、PHP のサンプルプログラムを実装してより具体的に理解を深めていきたいと思います。

早速進めて行こうと思います。

ちなみに、 PHPのサンプルプログラムはGithubで公開しているので、参考にしていただければと思います。

( 実行するだけであれば、 「Docker Compose」 がインストールされていれば動きます。 )

AWS Cognito の作成、設定

まずは AWS 側の設定を行っていきたいと思います。

1. 「Cognito」のユーザープールの作成

初めて Cognito にアクセスした場合、以下のようなページが表示されるはずです。

ユーザープールの管理 を選択して先に進みます。

ユーザープールを作成する をクリックします。

作成するユーザープール名を入力し、次に進みます。

ここでは g-user-pool というユーザープール名にしました。

後は次へ次へと進むだけです。

2.「Cognito」に新しい「アプリクライアント」の登録

「Cognito」のユーザープール設定ページから アプリクライアントの追加 をクリックします。

以下のように設定します。

  • アプリクライアント名 : aws-cognito-example ( ※こちらは好きな名前をつけましょう )
  • トークンの有効期限を更新 (日) : 1 ( 特にこだわりは有りませんでした。デフォルトの30日のままでも構いません。 )
  • クライアントシークレットを生成 : チェックを外す

3.「アプリクライアントの設定」

今度は左のタブから「アプリクライアントの設定」を開きます。
以下のように設定します。

  • 有効な ID プロバイダ : すべて選択
  • コールバック URL : http://localhost:8080/callback.php
  • 許可されている OAuth フロー : Authorization code grant ( Webアプリなので "Client credentials" 以外となるが、 "Implicit grant" は認証用途としてはふさわしくないそうです。 )
  • 許可されている OAuth スコープ : email / openid ( ID Token内に格納されるユーザメタ情報 )

設定できたら保存しましょう。

また、アプリクライアントの 「ID」 は後ほど必要になるのでメモしておきます。

4. ドメインの取得

Cognito 上でドメインを払い出します。

ご自分が所有しているドメインを割り当てることもできますが、今回は対象外です。

ここでは hello1 というドメインを入力しています。この後利用しますのでメモしておきましょう。

URL のルールは以下のようなルールとなります。

https://${ドメイン}.auth.${リージョンID}.amazoncognito.com

リージョンID の部分もこの後利用しますのでメモしておきましょう。

サンプルプログラムの作成

ここからはサンプルプログラムを作成していきます。
先述したとおり、同じものをGithubでも公開していますのでご参考ください。

1. トップページの作成

ログイン用のトップページを作成します。

html/ ディレクトリを作成し、作成してください。

html/index.php

<html>
  <body>
    <div id="container">
      <h1>Cognito OAuth2 Page</h1>

      <h2>Start OAuth2</h2>

      <ul>
<?php
// 設定が必要な環境変数名の一覧
$envVarNames = array(
    'COGNITO_DOMAIN',
    'COGNITO_REGION_ID',
    'COGNITO_USERPOOL_ID',
    'COGNITO_CLIENT_ID',
    'COGNITO_CALLBACK_URL',
);

// 最低限必要な環境変数の設定が行われているかをチェック
foreach ($envVarNames as $name) {
    if (!getenv($name)) {
        echo "<li><strong style=\"color:red\">\"${name}\" is not defined.</strong></li>";
    }
}
?>
      </ul>

      <ul>
        <li>
          <strong>
            <a href="https://<?php echo getenv('COGNITO_DOMAIN'); ?>.auth.<?php echo getenv('COGNITO_REGION_ID'); ?>.amazoncognito.com/login?response_type=code&client_id=<?php echo getenv('COGNITO_CLIENT_ID'); ?>&redirect_uri=<?php echo getenv('COGNITO_CALLBACK_URL'); ?>">LOGIN!</a>
          </strong>
        </li>
      </ul>
    </div>
  </body>
</html>

「LOGIN!」というリンクだけが配置されたシンプルなページです。

リンクをクリックすると「Cognito」のユーザ認証ページへ遷移します。

URL のルールは以下のようなルールとなります。

https://${ドメイン}.auth.${リージョンID}.amazoncognito.com/login?response_type=code&client_id=${クライアントID}&redirect_uri=${コールバックURL}

「ドメイン」 ( COGNITO_DOMAIN ) 、 「リージョン ID」 ( COGNITO_REGION_ID ) 、 「クライアント ID」 ( COGNITO_CLIENT_ID )、 「コールバック URL」 ( COGNITO_CALLBACK_URL ) は Web サーバ起動時に環境変数から受け取ることにしてみました。

後ほどサーバ起動の際に指定します。

Authorization code を取得した後、 ID Token を取得するという流れになりますが、その場合 response_type クエリ文字列には code という値をセットしておきます。

2. コールバックページの作成

「Cognito」のユーザ認証ページから正常にログインできた後にリダイレクトされるページを作成します。

先程アプリクライアントの設定ページで、 "コールバックURL" に設定していたページに該当します。

html/callback.php

<html>
  <body>
    <div id="container">
      <h1>Cognito Callback Page</h1>
<?php
    echo '<h2>Authentication Codeが取得できました!</h2>';

    // Cognitoに送るPOSTリクエストのボディ情報
    $data = array(
        'code' => $_GET['code'],
        'client_id' => getenv('COGNITO_CLIENT_ID'),
        'grant_type' => 'authorization_code',
        'redirect_uri' => getenv('COGNITO_CALLBACK_URL'),
    );

    // Cognitoに送るPOSTリクエストのURL
    $url = 'https://' . getenv('COGNITO_DOMAIN') . '.auth.' . getenv('COGNITO_REGION_ID') . '.amazoncognito.com/oauth2/token';

    // CognitoにPOSTリクエストを送信し、レスポンスJSONテキスト情報を取得
    $jwtJsonText = file_get_contents(
        $url,
        false,
        stream_context_create(
            array(
                'http' => array(
                    'method' => 'POST',
                    'header' => array(
                        'Content-Type: application/x-www-form-urlencoded',
                    ),
                    'content' => http_build_query($data),
                ),
            )
        )
    );

    if ($jwtJsonText) {
        echo '<h2>Authentication CodeをつかってID Tokenが取得できました!</h2>';

        $jwt = (array) json_decode($jwtJsonText);

        echo '<h3>ID Token</h3>';
        echo '<pre>';
        var_dump($jwt['id_token']);
        echo '</pre>';

        // ID Tokenをカンマ(".")で区切って2番目の要素にemailアドレス情報が付与されている
        echo '<h3>ID Token(base64 decoded)</h3>';
        echo '<pre>';
        var_dump(
            base64_decode(
                explode('.', $jwt['id_token'])[1],
                true
            )
        );
        echo '</pre>';
    }
?>

      <button onclick="moveNextPage()">ID Tokenを使って認証必要ページにアクセスしてみます。</button>
      <script>
    function moveNextPage() {
      let idToken = "<?php if (isset($jwt['id_token'])) echo $jwt['id_token']; ?>";

      document.location = "/check_id_token.php?id_token=" + idToken;
    }
      </script>
    </div>
  </body>
</html>

ログイン Cognito からこちらのページにリダイレクトされる際に、URLに code というクエリ文字列が付与されます。 ( 例 : http://localhost:8080/callback.php&code=XXXXX )

この code クエリ文字列が Authentication code と呼ばれるものになります。

Authorization code を使って、 ID Token を取得します。 ( ID Token を取得したタイミングで Authentication code は無効化されます。ワンタイムパスワードみたいなものですね。 )

最期に ID Token を次のページに引き渡していますが、 ※※※今回は省略してクエリ文字列として引き渡していますが、実際にはlocalStorageに格納するなどの方法をとりましょう。API呼び出しの場合はBearerヘッダーとして送ることが多いでしょう。絶対に真似をしないでください。※※※

3. 認証済みの場合にアクセス可能なページの作成

id_token をチェックするページを作ります。
( 例えばログイン済みにアクセス可能なマイページなどです。 )

html/ ディレクトリを作成し、作成してください。

html/check_id_token.php

<html>
  <body>
    <div id="container">
      <h1>Cognito Callback Page</h1>

      <h2>Check IdToken(JWT)</h2>

      <div id="id_token">
<?php

require_once __DIR__ . '/../vendor/autoload.php';

use Jose\Factory\JWKFactory;
use Jose\Loader;

try {
    $cognitoRegionId = getenv('COGNITO_REGION_ID');
    $cognitoUserpoolId = getenv('COGNITO_USERPOOL_ID');

    // 公開鍵を取得
    $jku = "https://cognito-idp.{$cognitoRegionId}.amazonaws.com/{$cognitoUserpoolId}/.well-known/jwks.json";
    $jwk = JWKFactory::createFromJKU($jku);

    // 「ID Token」を取得(普通はクエリ文字列で渡してはいけません!)
    // はBearerヘッダーに格納されているケースが多いでしょう。
    $idToken = $_GET['id_token'];

    // 「ID Token」の妥当性をチェック。問題があれば例外発生
    $loader = new Loader();
    $jws = $loader->loadAndVerifySignatureUsingKeySet(
        $idToken,
        $jwk,
        ['RS256'],
        $signatureIndex
    );

    $expire = $jws->getPayload()['exp'];
    $current = time();

    // ID Tokenの有効期限切れチェック
    if ($expire < $current) {
        throw new Exception("ID Token has expired. ");
    }

    echo '<strong style="color: green;">OK!</strong>';
    echo '<pre>';
    echo "email = {$jws->getPayload()['email']}";
    echo '</pre>';
} catch (Exception $e) {
    echo '<strong style="color: red;">NG!</strong>';
    echo '<pre>';
    var_dump($e);
    echo '</pre>';
}
?>
      </div>
  </body>
</html>

COGNITO_USERPOOL_ID の値も Web サーバ起動時に環境変数から受け取るようにしておきました。

「ID Token」のチェックために以下のライブラリを利用しました。

インストールは html/ ディレクトリではなく、親ディレクトリで行ってください。

$ composer require "spomky-labs/jose"

動作確認

動作を確認します。

以下のコマンドを実行し、PHP が実行可能なサーバを起動します。

# htmlフォルダに移動
$ cd html/

# php組み込みサーバを起動
$ COGNITO_DOMAIN='aaaaa' \
    COGNITO_REGION_ID='xxxxxxxxxxxxxx' \
    COGNITO_USERPOOL_ID='yyyyyyyyyyyyyyyyyyyyyyyy' \
    COGNITO_CLIENT_ID='zzzzzzzzzzzzzzzzzzzzzzzzzz' \
    COGNITO_CALLBACK_URL='http://localhost:8080/callback.php' \
    php -S localhost:8080

# 以下のように実行しても同様の挙動をします
$ export COGNITO_DOMAIN='aaaaa'
$ export COGNITO_REGION_ID='xxxxxxxxxxxxxx'
$ export COGNITO_USERPOOL_ID='yyyyyyyyyyyyyyyyyyyyyyyy'
$ export COGNITO_CLIENT_ID='zzzzzzzzzzzzzzzzzzzzzzzzzz'
$ export COGNITO_CALLBACK_URL='http://localhost:8080/callback.php'
$ php -S localhost:8080

COGNITO_DOMAIN / COGNITO_REGION_ID / COGNITO_USERPOOL_ID / COGNITO_CLIENT_ID / COGNITO_CALLBACK_URL のそれぞれの環境変数には、適切な ID 値を設定しましょう。(いずれも「Cognito」のユーザープールページから確認できます。)

うまく行けば以下のようなページが表示されます。

LOGIN! リンクをクリックすれば「Cognito」の認証フローに進みます。


「ID Token」の検証が終了すれば、取得できたメールアドレスに該当するユーザはログイン済み扱いとして、既存の Web アプリケーションのログイン済み処理へと進めばよいわけです。

ひとこと

ちょっと気になっているのがこの方式で本当に正しいのか?という点ですね。

なにか意図があるのか??

2023-03-27Amazon,AWS