AWS Cognitoを利用してLaravelアプリに認証機能を実装する

`artisan` コマンドで簡単に認証機能を実装できるLaravelですが、認証情報の管理は自DBで行うことになります。
今回はよりセキュリティを高めるために、AWS Cognitoに認証情報と機能を切り出した構成で実装してみることにします。

ですが [公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html) では主に、iOS/Androidアプリやjavascriptアプリへ組み込む例が紹介されています。
[AWS SDK for PHP](https://docs.aws.amazon.com/ja_jp/sdk-for-php/v3/developer-guide/welcome.html) も用意されてはいるのですが、有用な日本語情報がかなり少なかったので概要を紹介したいと思います。

# [AWS Cognito](https://aws.amazon.com/jp/cognito/) とは
AWSが提供するアクセスコントロールサービスです。
IDプロバイダとして認証情報を保管し、トークンを払い出してくれるものです。

> Amazon Cognito は、ウェブアプリケーションやモバイルアプリケーションの認証、許可、ユーザー管理をサポートしています。ユーザーは、ユーザー名とパスワードを使用して直接サインインするか、Facebook、Amazon、Google などのサードパーティーを通じてサインインできます。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html

Cognitoには以下の2種類の管理プールがあります。
### ユーザープール
>ユーザプールは Amazon Cognito のユーザディレクトリです。ユーザープールを使用すると、ユーザーは Amazon Cognito を通じてウェブまたはモバイルアプリにログインできます。また、ユーザーは Google、Facebook、Amazon などのソーシャル ID プロバイダー、および SAML ベースの ID プロバイダー経由でユーザープールにサインインすることもできます。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-identity-pools.html

### IDプール
>Amazon Cognito ID プール (フェデレーテッドアイデンティティ) では、ユーザーの一意の ID を作成し、ID プロバイダーで連携させることができます。ID プールを使用すると、権限が制限された一時的な AWS 認証情報を取得して、他の AWS サービスにアクセスできます。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-identity.html

今回はWebアプリへのログインに使用したいので、 **ユーザープール** を使用します。


# 手順
LaravelアプリにCognitoを使用したログイン機能を実装するために、以下の流れで作業していきます。

1. Cognitoユーザープールを作成
2. AWS SDK for PHPをインストール
3. Laravelアプリ内にCognitoとの連携を実装

## 1. Cognitoユーザープールを作成
まずは認証情報を管理する場所を作ります。
[こちら](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pool-as-user-directory.html) を参考にしてください。

外部のアプリケーションからCognitoへリクエストするためにトークン（アプリクライアントID/シークレット）を払い出す必要があります。
「アプリクライアント」を作成し、以下のように設定します。
![ユーザープール - アプリクライアント](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395008/e3977fc1-8af1-21b8-9050-e5d7a1126092.png)

- 作成時に **"クライアントシークレットを生成"** に✅を入れて、アプリクライアントのシークレットも生成します
- 今回はバックエンドアプリからのリクエストなので **"認証用の管理 API のユーザー名パスワード認証を有効にする (ALLOW_ADMIN_USER_PASSWORD_AUTH)"** に✅を入れて `ADMIN_USER_PASSWORD_AUTH` の認証フローを使用できるようにしておきます。

参考：
[📖 ユーザープールのアプリクライアントの設定](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-client-apps.html)
[📖 ユーザープール認証フロー - サーバー側の認証フロー](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html#amazon-cognito-user-pools-server-side-authentication-flow)

## 2. AWS SDK for PHPをインストール
PHPで実装するためにSDKをインストールします。
[こちら](https://docs.aws.amazon.com/ja_jp/sdk-for-php/v3/developer-guide/getting-started_index.html) を参考にしてください。

## 3. Laravelアプリ内にCognitoとの連携を実装
ここからが本題です👩‍💻
今回は以下のようにして認証機能を実装しました。

1. Cognitoへ認証情報（ID/password）をリクエスト
2. 認証に成功したら、払い出されたIDトークンとリフレッシュトークンをCookieに保存
3. middlewareでIDトークンを検証
4. IDトークンが正しいと検証できればログインが必要なページを表示、または処理を実行する


# 実装
[CognitoユーザープールAPI](https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_Operations.html) には接頭辞 "Admin" がついたものがいくつか用意されています。
これらは管理者として操作できるもので、インスタンス化する際にcredentials（IAMユーザー情報）が必要です。

## 1. Cognitoへ認証情報（ID/password）をリクエスト
[こちらの記事](https://blackbits.io/blog/laravel-authentication-with-aws-cognito) を参考にさせていただきました🙇‍♀️

まずはじめに、 [adminInitiateAuth](https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_AdminInitiateAuth.html) で認証情報をCognitoへリクエストします。
今回アカウントはユーザーがサインアップするのではなく、管理者が発行する仕様なのでいきなりここからです。
"AuthFlow" には `ADMIN_NO_SRP_AUTH` を指定します。（最新では `ADMIN_USER_PASSWORD_AUTH` に置き換わったようです）

```php:CognitoService.php

namespace App\Services;

use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;

class CognitoService
{
  private $library_version;
  private $region;
  private $access_key;
  private $secret_key;
  private $user_pool_id;
  private $client_id;
  private $client_secret;
  private $COGNITO_CLIENT;

  public function __construct()
  {
    $this->library_version = config('auth.aws.library_version');
    $this->region          = config('auth.aws.default_region');
    $this->access_key      = config('auth.aws.access_key');
    $this->secret_key      = config('auth.aws.secret_key');
    $this->user_pool_id    = config('auth.cognito.user_pool_id');
    $this->client_id       = config('auth.cognito.client_id');
    $this->client_secret   = config('auth.cognito.client_secret');
  }

  private function adminInstantiation(): void
  {
      $this->COGNITO_CLIENT = new CognitoIdentityProviderClient(
          [
              'version'     => $this->library_version,
              'region'      => $this->region,
              'credentials' => [
                  'key'    => $this->access_key,
                  'secret' => $this->secret_key,
              ],
          ]
      );
  }

  /**
   * ユーザー認証
   *
   * @param  string  $username
   * @param  string  $password
   *
   * @return array
   */
  public function authenticate(string $username, string $password): array
  {
      try {
          $this->adminInstantiation();
          $response = $this->COGNITO_CLIENT->adminInitiateAuth(
              [
                  'AuthFlow'       => 'ADMIN_NO_SRP_AUTH',
                  'AuthParameters' => [
                      'USERNAME'    => $username,
                      'PASSWORD'    => $password,
                      'SECRET_HASH' => self::cognitoSecretHash($username),
                  ],
                  'ClientId'       => $this->client_id,
                  'UserPoolId'     => $this->user_pool_id,
              ]
          );
      } catch (InvalidArgumentException $e) {
          return [
              'result'  => false,
              'message' => __('validation.custom.auth_id.form'),
          ];
      } catch (CognitoIdentityProviderException $exception) {
          $errorCode = $exception->getAwsErrorCode();

          // エラーメッセージ
          $errorMessage = __('auth.other');
          if ($errorCode === self::NOT_AUTHORIZED) {
              if (strpos($exception->getAwsErrorMessage(), 'exceeded') == false) {
                  $errorMessage = __('auth.incorrect');
              } else {
                  // 試行回数超過
                  $errorMessage = __('auth.exceeded');
              }
          } elseif ($errorCode === self::TOO_MANY_REQUESTS) {
              $errorMessage = __('auth.too_many');
          } else {
              // 上記以外のエラーコードの場合
          }

          return [
              'result'  => false,
              'message' => $errorMessage,
          ];
      }

      return [
          'result' => true,
          'data'   => $response->toArray(),
      ];
  }

  protected function cognitoSecretHash($username)
  {
    return $this->hash($username.$this->client_id);
  }

  protected function hash($message)
  {
    $hash = hash_hmac(
      'sha256',
      $message,
      $this->client_secret,
      true
    );

    return base64_encode($hash);
  }
}
```

adminInitiateAuth のリクエスト項目にある `SECRET_HASH` の詳細については以下を参照してください。

参考：
[ユーザーアカウントのサインアップと確認 - SecretHash 値の計算](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash)

## 2. 認証に成功したら、払い出されたIDトークンとリフレッシュトークンをCookieに保存

adminInitiateAuth で無事認証されると、Cognitoから３種類のトークンが返されます。

> Amazon Cognito ユーザープールには、OpenID Connect (OIDC) オープン標準で定義されている ID トークン、アクセストークン、および更新トークンが実装されています。
・ID トークンには、name、email、phone_number といった、認証されたユーザーの ID に関するクレームが含まれます。
・アクセストークンは、スコープとグループを含み、承認されたリソースへのアクセスを許可するために使用されます。
・更新トークンには、新しい ID またはアクセストークンの取得に必要な情報が含まれます。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html

このうち **IDトークン** と **リフレッシュトークン** をCookieに保存します。

## 3. middlewareでIDトークンを検証
ユーザーが先ほどのIDトークンを伴ってリクエストしてきたときに、それが正しいか検証することでログイン状態であると判断します。
モバイルアプリ向けのSDKなどではこのへんをいい感じに使いやすくしてくれているようですが、for PHPではそうでなかったので理解するのに苦労しました。

[ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) を参考に、IDトークン（JWT）をデコードして検証するのに以下のライブラリを使用しました。

- [firebase/php-jwt](https://github.com/firebase/php-jwt) ：JWTの検証
- [acodercat/php-jwk-to-pem](https://github.com/acodercat/php-jwk-to-pem) ：JWKの変換

この検証処理をmiddlewareから呼び出すようにします。

## 4. IDトークンが正しいと検証できればログインが必要なページを表示、または処理を実行する
middlewareで先ほどのIDトークン検証処理を実行し、検証に成功した場合は `return $next($request);` 、失敗した場合はログイン画面にリダイレクトさせます。


# まとめ
そもそもCognitoってなんか便利そう！使えそう！ぐらいのテンションで採用しましたが、ふたを開けてみるとOpenID Connectの仕組みをちゃんと理解できていなくて、まずそこから始まりました🙄
OpenID Connectについてはこちらを参考にさせていただきました🙇‍♀️
[一番分かりやすい OpenID Connect の説明](https://qiita.com/TakahikoKawasaki/items/498ca08bbfcc341691fe)


また、Laravel + Cognitoで実装しようとした当時（2019年5月ごろ）は "cognito laravel" なんかでぐぐっても情報がほとんどありませんでした。
あったとしてもサインアップまでのものが多く、そのあとアプリケーション側でどう認証するの？というところで悩みました。

いま検索してみると、とても素敵な記事を見つけることができましたのでリンクさせていただきます。
こちらではちゃんとLaravelのGuardを利用するかたちで実装されています。
わたしはLaravelへの造詣もそこまで深くなかったので、この方法には至れませんでした。。

[Laravel + AWS Cognito での認証機能の実装【ユーザー新規登録編】](https://qiita.com/ayasamind/items/9b07c8c2fd433505a0eb)
[Laravelのユーザ認証をAmazon Cognitoで行う方法](https://qiita.com/suzumurakk/items/efe4543cf0df2cd31659)

結果的にCognitoで認証の情報管理、および処理を賄うことができたのは、実装面でも運用面でもメリットが多かったと感じています。
メールアドレス登録時に検証コードを送信してくれるのはありがたいですし、管理画面上でユーザーの検索、無効化なども簡単にできます。
一度仕組みを理解さえしてしまえば、だいぶ簡単にログイン機能を導入できるなという感じです。