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

2019-05-26Amazon, AWS

はじめに

既存のWebアプリで「Amazon Cognito」認証を利用する(その1:処理フローの整理) | ゲンゾウ用ポストイット で「Congito」の認証の流れを整理できたので、PHPのサンプルプログラムを実装してみます。
サンプルプログラムは「SPA」(シングルページアプリケーション)ではなく、紙芝居型のWebアプリケーションを想定して実装してみます。

環境

  1. ローカルPCでPHPのWebアプリケーションが動作すれば何でも構いません。
    • ( ex : php -S localhost:8080
  2. 「ID Token」の改ざんチェックライブラリを利用するため 「Composer」 をインストールしておきましょう。

ちなみに、今回作成するアプリケーションコードは「Github」に公開しておきました。
こちらは 「Docker」「Docker Compose」 がインストールされていれば起動可能です。

構築

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

既存のWebアプリで「Amazon Cognito」認証を利用する(その1:処理フローの整理) | ゲンゾウ用ポストイット で構築した「アプリクライアント」を利用してもよいですが、
今回は新規作成から始めたいと思います。

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

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

  • アプリクライアント名 : aws-cognito-example ( ※こちらは好きな名前をつけましょう )
  • トークンの有効期限を更新 (日) : 1 ( 既存のWebアプリケーションでログイン済み判定のために一時的に利用したいだけなので短くしています )
  • クライアントシークレットを生成 : チェックを外す

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

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

  • 有効な ID プロバイダ : すべて選択
  • コールバック URL : http://localhost:8080/callback.php
  • 許可されている OAuth フロー : Implicit grant

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

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

3. トップページの作成

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

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=token&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=token&client_id=${クライアントID}&redirect_uri=${コールバックURL}

「ドメイン」 ( COGNITO_DOMAIN ) 、 「リージョンID」 ( COGNITO_REGION_ID ) 、 「クライアントID」 ( COGNITO_CLIENT_ID )、 「コールバックURL」 ( COGNITO_CALLBACK_URL ) はWebサーバ起動時に環境変数から受け取ることにしましょう。後ほどサーバ起動の際に指定します。(クライアントIDは先程メモしておきましたね。)

「ID Token」を取得したい場合には response_type クエリ文字列に token という値をセットしておきます。

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

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

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

html/callback.php

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

      <em>自動的に遷移します...</em>
    </div>
    <script>
      // URLからフラグメント、つまり `#` より後ろの部分を取り出す
      let fragment = document.location.href.replace(/^.*#/mgi, "");
      // フラグメントから "id_token=~" の部分を取り出す
      let idToken = fragment.split("&").find((text) => text.indexOf("id_token") === 0).replace(/^id_token=/mgi, "")

      document.location = `/check_id_token.php?id_token=${idToken}`
    </script>
  </body>
</html>

重要なのはJavaScriptです。
URLから id_token=xxx を取り出す処理をしているだけですが、多少わかりにくくなってしまいました。
取り出した id_token は次のページへと引き渡します。

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

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」を取得
    $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アプリケーションのログイン済み処理へと進めばよいわけです。

ひとこと

ちょっと気になっているのがこの方式で本当に正しいのか?という点ですね。
CallbackページへのリダイレクトURLに付与されている id_token ですが、 クエリ文字列 ではなく フラグメント となっています。
つまり、このタイミングではCallback先のWebアプリには id_token が飛ばず、ブラウザがよしなに操作できるようになっているわけです。

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

2019-05-25追記

「Cognito」の認証ページURLに付与していた response_type=token の部分を response_type=code に変更しても、サーバサイドPHPのロジックで認証を行う方法が実現できました。

クライアントサイドに id_token を引き渡す必要がなかったり、認証が確立できたらワンタイムトークンを破棄できたりと何かと良さそうです。

2019-05-26Amazon, AWS