Cognitoを利用したLaravelでの認証

## 前提

[一般的な Amazon Cognito シナリオ](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-scenarios.html)の2番目にある「ユーザープールを使用してサーバー側のリソースにアクセスする」で、BackendをLaravelで構築する方法です。

具体的なゴールとしては、クライアントから送られてきたJWTを検証し、CognitoのIDからUserを識別し、Laravelの`Auth::user()`メソッドでUserが取得できるところまでを目指します。

Laravelプロジェクトの作成方法などは説明しませんので、自分の環境に合わせて適宜読み替えてください。

# Cognitoの設定

1. ユーザープールの作成
2. アプリクライアントの作成


## ユーザープールの作成

AWSコンソールからCognitoを選択し、「ユーザープールの管理」 → 「ユーザープールを作成する」をクリックする。
プール名を入力し、「デフォルトを確認する」ボタンを押す。

設定を確認し、「プールの作成」ボタンを押す。

※ ユーザーネームでは無く、メールアドレスでログインさせたい場合は次のように設定を変更する。**後から変更できないので注意**。
![cognito1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/1756e08d-5395-7e5d-1161-3130c868f1ca.png)
![cognito2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/aa23e855-46c7-3a71-fce3-85357bc8c80a.png)

## アプリクライアントの作成
左メニューから「アプリクライアント」を選択する。
![スクリーンショット 2020-09-28 14.55.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/3eb1da43-9a74-b33c-c5a5-08cb894fa2ab.png)

「アプリクライアント名」には、このユーザープールを実際に利用するアプリが一意に識別できる名前を入力する。

認証フローの設定は「サーバーからユーザープールへAPIアクセスをする」なら①、「クライアントアプリからユーザープールへAPIアクセスをする」なら②にチェックを入れる。

サーバー側からAPIアクセスを行う際は、クライアントシークレットを用いることでセキュアな通信を行う。しかし、クライアント側ではセキュリティの観点からクライアントシークレットを端末に保存することができない。その代わりに、Android用、iOS用、Javascript用に用意された各種SDKがセキュアな通信を担保してくれている[^flow]。

また、APIアクセスというのは、ユーザーネームとパスワードからJWTを取得したり、アクセストークンを利用してユーザープールの情報を取得することを指し、サーバーでJWTのデコードを行うだけならAPIアクセスが必要無い点に注意。

![client.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/e4411fe8-3a1f-b1dc-4536-c19ff9854729.png)

# Laravelでの認証

1. JWTの検証サービスを作成
2. デコードした情報からUserを取得するGuardを作成
3. 作成したGuardを使用する設定を行う

## JWTの検証サービスを作成

JWTの検証についてのマニュアルは[こちら](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html)。

具体的なステップとしては
1. JWKのダウンロード
2. JWTのヘッダーから`kid`を取得
3. `kid`から証明書を選択
4. 証明書を用いてデコード

デコードの処理には`firebase/php-jwt`を用いる。具体的な実装の例は以下の通り。

```php
<?php

namespace App\Services\Cognito;

use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Http;

class JWTVerifier
{
    /**
     * @param string $jwt
     * @return object|null
     */
    public function decode(string $jwt)
    {
        $tks = explode('.', $jwt);
        if (count($tks) !== 3) {
            return null;
        }
        [$headb64, $_, $_] = $tks;

        $jwks = $this->fetchJWKs();
        try {
            $kid = $this->getKid($headb64);
            $jwk = $this->getJWK($jwks, $kid);
            $alg = $this->getAlg($jwks, $kid);
            return JWT::decode($jwt, $jwk, [$alg]);
        } catch (\RuntimeException $exception) {
            return null;
        }
    }

    private function getKid(string $headb64)
    {
        $headb64 = json_decode(JWT::urlsafeB64Decode($headb64), true);
        if (array_key_exists('kid', $headb64)) {
            return $headb64['kid'];
        }
        throw new \RuntimeException();
    }

    private function getJWK(array $jwks, string $kid)
    {
        $keys = JWK::parseKeySet($jwks);
        if (array_key_exists($kid, $keys)) {
            return $keys[$kid];
        }
        throw new \RuntimeException();
    }

    private function getAlg(array $jwks, string $kid)
    {
        if (!array_key_exists('keys', $jwks)) {
            throw new \RuntimeException();
        }

        foreach ($jwks['keys'] as $key) {
            if ($key['kid'] === $kid && array_key_exists('alg', $key)) {
                return $key['alg'];
            }
        }
        throw new \RuntimeException();
    }

    private function fetchJWK(): array
    {
        $response = Http::get('https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json');
        return json_decode($response->getBody()->getContents(), true) ?: [];
    }
}
```

JWTのデコードには、対応する証明書が必要である。先に述べたマニュアルに書いてある通り、証明書は`https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json`からダウンロードしてくる。`{region}`はユーザープールを作成したリージョン、`{userPoolId}`は、ユーザープールの管理画面の左メニューの「前半設定」を押すと確認することができる。


```php

    private function fetchJWKs(): array
    {
        $response = Http::get('https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json');
        return json_decode($response->getBody()->getContents(), true) ?: [];
    }
```

![pool id.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/ae313980-ee9f-09fc-1d8b-abc3899f64e1.png)


## デコードした情報からUserを取得するGuardを作成

Laravelには認証の仕組みとして、`Guard`という物がある[^guard]。
`Guard`自体は下記のような`interface`で、この中で重要なのが`user()`メソッドである。この`user()`メソッドで`Authenticatable`を実装したインスタンス(大抵の場合は`User model`)を返すことで、Authファサードを使い`$user = Auth::user()`のようにアプリケーション内でユーザーを取得することができるようになる。

Laravelには元々いくつか`Guard`の実装が提供されているが、今回はJWTをデコードした結果からUserを取得して返すような実装を新たに作り、アプリケーションでそれを使用するような設定を行う。


```php

<?php

namespace Illuminate\Contracts\Auth;

interface Guard
{
    /**
     * Determine if the current user is authenticated.
     *
     * @return bool
     */
    public function check();

    /**
     * Determine if the current user is a guest.
     *
     * @return bool
     */
    public function guest();

    /**
     * Get the currently authenticated user.
     *
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function user();

    /**
     * Get the ID for the currently authenticated user.
     *
     * @return int|string|null
     */
    public function id();

    /**
     * Validate a user's credentials.
     *
     * @param  array  $credentials
     * @return bool
     */
    public function validate(array $credentials = []);

    /**
     * Set the current user.
     *
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
     * @return void
     */
    public function setUser(Authenticatable $user);
}

```



Guardの実装の一例を以下に示す。

```php:app/Services/cognito/CognitoGuard.php

<?php

namespace App\Services\Auth;

use App\Services\Cognito\JWTVerifier;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Http\Request;

class CognitoGuard implements Guard
{
    use GuardHelpers;

    /**
     * @var JWTVerifier
     */
    private $JWTVerifier;

    /**
     * @var Request
     */
    private $request;

    /**
     * @var UserProvider
     */
    private $userProvider;

    /**
     * CognitoGuard constructor.
     * @param Request $request
     * @param UserProvider $userProvider
     * @param JWTVerifier $JWTVerifier
     */
    public function __construct(
        JWTVerifier $JWTVerifier,
        Request $request,
        UserProvider $userProvider
    ) {
        $this->JWTVerifier = $JWTVerifier;
        $this->request = $request;
        $this->userProvider = $userProvider;
    }

    public function user()
    {
        if ($this->user) {
            return $this->user;
        }

        $jwt = $this->request->bearerToken();
        if (!$jwt) {
            return null;
        }

        $decoded = $this->JWTVerifier->decode($jwt);
        if ($decoded) {
            return $this->userProvider->retrieveByCredentials(['cognito_sub' => $decoded->sub]);
        }

        return null;
    }

    public function validate(array $credentials = [])
    {
        throw new RuntimeException('Cognito guard cannot be used for credential based authentication.');
    }
}
```

### 解説


コンストラクタでは、先ほど作成した`JWTVerifier`と`Request`、そして`UserProvider`を受け取る。
`user()`メソッドでは、`Request`からBearerトークンを取得し、`JWTVerifier`で検証 & デコードを行う。そして、`UserProvider`を用いて、DBからJWTに含まれていた'sub'と一致するUserを取得する。
また、`user()`メソッドと`validate()`メソッド以外は`GuardHelpers`traitを使用することで自動で実装することができる。

## 作成したGuardを使用する設定を行う

まずは、作成した`CognitoGuard`をサービスとして登録する。`app/Providers/AuthServiceProviders.php`の中にある`boot()`メソッドを以下のようにする。これで、`CognitoGuard`が'cognito'という名前でサービスに登録される。

```php:app/Providers/AuthServiceProviders.php
...
    public function boot()
    {
        $this->registerPolicies();

        Auth::extend('cognito', function($app, $name, array $config) {
            return new CognitoGuard(
                new JWTVerifier(),
                $app['request'],
                Auth::createUserProvider($config['provider'])
            );
        });
    }
...
```

続いて、`config/auth.php`を編集する。
'defaults'で指定されている'guard'を'api'に変更する。そして、'guards'の中の'api'で指定されている'driver'を'cognito'に変更する。このようにすることで、アプリケーションで使用される`Guard`を`CognitoGuard`に変更することができる。

また、ここで指定されている`provider`が`CognitoGuard`の`Provider`として渡される。

```php:config/auth.php

return [
...
    'defaults' => [
        'guard' => 'api',  // 変える
        'passwords' => 'users',
    ],


...
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'api' => [
            'driver' => 'cognito',  // 変える
            'provider' => 'users',
            'hash' => false,
        ],
    ],
...
```

# 実際に認証してみる

実際の現場では、サーバーじゃフロントから送られてきたJWTを検証するだけなので、自らCognitoへJWTを取得しに行くことは無いと思う。
しかし、今回の実装でほんとに認証が行えるのかを検証するために、Cognitoのユーザー作成とJWTを取得するためのLaravelコマンドを作成した。

## Userモデルに`cognito_sub`カラムを追加

以下2つのファイルを編集し、Userモデルに`cognito:username`カラムを作成する。

```php:app/Models/User.php

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected $fillable = ['name', 'cognito_sub',];
}
```

```php:database/migrations/2014_10_12_000000_create_users_table.php
...

    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('cognito_sub')->unique();
            $table->timestamps();
        });
    }
...
```

BDのマイグレーションを行う

```bash

$ ./artisan migrate
```


## CognitoのUser作成/JWT取得コマンドを作る

AWSのSDKをインストールする。

```php

$ composer req aws/aws-sdk-php
```

.envに以下の項目を設定する。`AWS_ACCESS_KEY_ID`、`AWS_SECRET_ACCESS_KEY`はIAMからUserを作成して取得する。この時、作成したUserにはCognitoに対するアクセス権が必要なので注意すること。検証だけなら`AmazonCognitoPowerUser`ポリシーをアタッチすればOK。

`AWS_COGNITO_CLIENT_SECRET`はCognitoのアプリクライアントに記述されている。

```:.env
AWS_ACCESS_KEY_ID=xxxxx
AWS_SECRET_ACCESS_KEY=xxxxx
AWS_DEFAULT_REGION=ap-northeast-1
AWS_COGNITO_CLIENT_ID=xxxxx
AWS_COGNITO_CLIENT_SECRET=xxxxx
AWS_COGNITO_USER_POOL_ID=xxxxx
```

以下のコマンドを作成する。

```php:app/Console/Command/CognitoCommand.php

<?php

namespace App\Console\Commands;

use App\Models\User;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Illuminate\Console\Command;

class CognitoCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'myapp:cognito {email} {password} {method}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Create/Auth User';

    private $library_version;
    private $region;
    private $access_key;
    private $secret_key;
    private $client_id;
    private $client_secret;
    private $user_pool_id;

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();

        $this->library_version = 'latest';
        $this->region          = env('AWS_DEFAULT_REGION');
        $this->access_key      = env('AWS_ACCESS_KEY_ID');
        $this->secret_key      = env('AWS_SECRET_ACCESS_KEY');
        $this->client_id       = env('AWS_COGNITO_CLIENT_ID');
        $this->client_secret   = env('AWS_COGNITO_CLIENT_SECRET');
        $this->user_pool_id    = env('AWS_COGNITO_USER_POOL_ID');
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $email = $this->argument('email');
        $password = $this->argument('password');
        $method = $this->argument('method');

        if (!in_array($method, ['auth', 'signUp'])) {
            $this->output->error('method is allowed auth or signUp');
            return 1;
        }

        $this->$method($email, $password);
        return 0;
    }

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

    private function signUp(string $email, string $password)
    {
        $response = $this
            ->adminInstantiation()
            ->signUp([
                'ClientId' => $this->client_id,
                'Username' => $email,
                'Email' => $email,
                'Password' => $password,
                'UserAttributes' => [
                    [
                        'Name' => 'email',
                        'Value' => $email,
                    ]
                ],
                'SecretHash' => $this->cognitoSecretHash($email),
            ]);
        User::create(['email' => $email, 'cognito_sub' => $response->toArray()['UserSub'],]);
        $this->output->success(sprintf('created %s', $email));
    }

    private function auth(string $email, string $password)
    {
        $response = $this
            ->adminInstantiation()
            ->adminInitiateAuth([
                'AuthFlow' => 'ADMIN_USER_PASSWORD_AUTH',
                'ClientId' => $this->client_id,
                'UserPoolId' => $this->user_pool_id,
                'AuthParameters' => [
                    'USERNAME' => $email,
                    'PASSWORD' => $password,
                    'SECRET_HASH' => $this->cognitoSecretHash($email),
                ],
            ]);
        $this->output->success(sprintf('token for %s', $email));
        $this->output->writeln($response->toArray()['AuthenticationResult']['IdToken']);
    }

    protected function cognitoSecretHash($username)
    {
        $hash = hash_hmac('sha256', $username.$this->client_id, $this->client_secret, true);
        return base64_encode($hash);
    }

}
```

ターミナルで以下のコマンドを実行すると、CognitoとDBにUserが作成される。

```bash
$ ./artisan myapp:cognito test@example.com password signUp
```

Cognitoの管理画面に移動すると以下のような表示になっており、Userが追加されているのがわかる。しかし、まだこのユーザーはステータスが`UNCONFITMED`なので認証を行うことができない。図のように、「アカウント名をクリック」 → 「ユーザーの確認」を押すことで、アカウントを`CONFIRMED`にすることができる。

![user.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/fdc9a72c-2879-ed34-b86c-c3e077798277.png)

![confitm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/cb2d5409-686f-97d4-b92e-c6cb58ff247d.png)


この後でターミナルで以下のコマンドを実行すると、トークンが発行される。

```bash
$ ./artisan myapp:cognito test@example.com password auth

                                                                                                                        
 [OK] token for test@example.com                                                                                        
                                                                                                                        

eyJraWQiOiJreEsyblNMWkwwQjdRRjh...

```

発行されたトークンを[こちら](https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj?hl=ja)のchrome拡張等を用いて`Authorization: Bearer {token}`としてヘッダーに設定する。

その状態で`http://127.0.0.1:8000/api/user`にアクセスすると、ログインしているUserの情報が表示される。

![スクリーンショット 2020-09-30 23.03.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/8cb64aa0-dea2-5df8-cc43-6c773869b814.png)


# 今回のソース

https://github.com/ggg-mzkr/laravel-cognito

# 参考文献
[^flow]: https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html#amazon-cognito-user-pools-admin-authentication-flow
[^guard]: https://readouble.com/laravel/6.x/ja/authentication.html