Laravelのユーザ認証をAmazon Cognitoで行う方法

# はじめに
多くの方がLaravelでCognitoを使う方法をまとめてくださっていますが、
アレ？できない！？ となることが多かったので、自分なりにまとめてみます。

# 前提条件
- Laravel 6.1.0で動作確認しました。
- 「*php artisan ui \*\*\* --auth*」で認証系のビューが自動生成されているものとします。
- aws/aws-sdk-phpがインストール済であるものとします。
- Amazon Cognitoの詳細な設定手順については割愛します。

# Amazon Cognitoの設定（概略）
今回は、以下の設定内容を前提とした場合の実装とします。

- Eメールアドレスで認証
- カスタム属性「custom:hoge」を追加（書き込み権限を付与）
- クライアントシークレットを生成**しない**
- サーバーベースの認証でサインインAPIを有効にする

# 環境変数
以下の変数を設定します。

```text:.env
// ...
AWS_ACCESS_KEY_ID=＝(IAMユーザーのアクセスキーID)
AWS_SECRET_ACCESS_KEY=(IAMユーザーのシークレットアクセスキー)
AWS_DEFAULT_REGION=(ユーザープールを作成したリージョン)

AWS_COGNITO_VERSION=2016-04-18
AWS_COGNITO_USER_POOL_ID=(作成したユーザープールのID)
AWS_COGNITO_APP_CLIENT_ID=(追加したアプリクライアントのID)
```

# 共通モジュール
まず、Cognitoと通信するAPIクライアントを作成します。
例外処理は要件に応じて実装してください。

```app\Cognito\CognitoClient.php
class CognitoClient
{
    protected $client;
    protected $clientId;
    protected $poolId;

    public function __construct(CognitoIdentityProviderClient $client, $clientId, $poolId)
    {
        $this->client = $client;
        $this->clientId = $clientId;
        $this->poolId = $poolId;
    }

    public function register($email, $password, array $attributes = [])
    {
        $attributes['email'] = $email;
        try {
            $response = $this->client->signUp([
                'ClientId' => $this->clientId,
                'Password' => $password,
                'UserAttributes' => $this->formatAttributes($attributes),
                'Username' => $email
            ]);
        } catch (CognitoIdentityProviderException $e) {
            throw $e;
        }
        return;
    }

    public function verify(string $email, string $code)
    {
        try {
            $response = $this->client->confirmSignUp([
                'ClientId' => $this->clientId,
                'Username' => $email,
                'ConfirmationCode' => $code,
            ]);
        } catch (CognitoIdentityProviderException $e) {
            throw $e;
        }
        return;
    }

    public function authenticate($email, $password)
    {
        try {
            $response = $this->client->adminInitiateAuth([
                'AuthFlow' => 'ADMIN_NO_SRP_AUTH',
                'AuthParameters' => [
                    'USERNAME' => $email,
                    'PASSWORD' => $password,
                ],
                'ClientId' => $this->clientId,
                'UserPoolId' => $this->poolId
            ]);
        } catch (CognitoIdentityProviderException $e) {
            return false;
        }
        return true;
    }

    protected function formatAttributes(array $attributes)
    {
        $userAttributes = [];
        foreach ($attributes as $key => $value) {
            $userAttributes[] = [
                'Name' => $key,
                'Value' => $value,
            ];
        }
        return $userAttributes;
    }
}

```

続いて、サービスプロバイダを作成します。

```app\Providers\CognitoAuthServiceProvider.php
namespace App\Providers;

use App\Auth\CognitoGuard;
use App\Cognito\CognitoClient;
use Illuminate\Support\ServiceProvider;
use Illuminate\Foundation\Application;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;

class CognitoAuthServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->app->singleton(CognitoClient::class, function (Application $app) {
            $config = [
                'region' => env('AWS_DEFAULT_REGION'),
                'version' => env('AWS_COGNITO_VERSION'),
            ];
            return new CognitoClient(
                new CognitoIdentityProviderClient($config),
                env('AWS_COGNITO_APP_CLIENT_ID'),
                env('AWS_COGNITO_USER_POOL_ID')
            );
        });

        $this->app['auth']->extend('cognito', function (Application $app, $name, array $config) {
            $guard = new CognitoGuard(
                $name,
                $client = $app->make(CognitoClient::class),
                $app['auth']->createUserProvider($config['provider']),
                $app['session.store'],
                $app['request']
            );
            $guard->setCookieJar($this->app['cookie']);
            $guard->setDispatcher($this->app['events']);
            $guard->setRequest($this->app->refresh('request', $guard, 'setRequest'));
            return $guard;
        });
    }
}
```

作成したサービスプロバイダをアプリケーションに追加します。

```（抜粋）config\app.php
    'providers' => [
        // ...
        App\Providers\CognitoAuthServiceProvider::class,
    ],
```

# ユーザ登録
コントローラをカスタマイズします。
アプリケーションにパスワードを保存させないために、登録処理の引数から「password」を削除しています。
（DBのNOT NULL制約も削除してください）

```（抜粋）app\Http\Controllers\Auth\RegisterController.php
    protected function create(array $data)
    {
        return User::create([
            'name' => $data['name'],
            'email' => $data['email'],
//          'password' => Hash::make($data['password']),
        ]);
    }

    public function register(Request $request)
    {
        $this->validator($request->all())->validate();
        $attributes = [];
        $userFields = ['name', 'email', 'custom:hoge'];
        foreach($userFields as $userField) {
            if ($request->$userField === null) {
                throw new \Exception("The configured user field $userField is not provided in the request.");
            }
            $attributes[$userField] = $request->$userField;
        }
        app()->make(CognitoClient::class)->register($request->email, $request->password, $attributes);
        event(new Registered($user = $this->create($request->all())));
        return $this->registered($request, $user) ?: redirect($this->redirectPath());
    }
```
また、コードは割愛しますが *register.blade.php* に「custom:hoge」の入力コントロールを追加してください。

# ユーザ登録の確認
ユーザ登録を行うと、登録したメールアドレスに確認コードが送信されます。
ログインするためには、この確認コードでメールアドレスを認証しておく必要があります。
詳細なコードは割愛しますが、APIクライアントの認証メソッドを実行してください。

```（抜粋）app\Http\Controllers\Auth\VerifyController.php
        app()->make(CognitoClient::class)->verify($request->email, $request->code);
```


# ログイン
Cognitoを使うガードを作成します。

```app\Auth\CognitoGuard.php
namespace App\Auth;

use App\Cognito\CognitoClient;
use App\Exceptions\InvalidUserModelException;
use Illuminate\Auth\SessionGuard;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
use Symfony\Component\HttpFoundation\Request;

class CognitoGuard extends SessionGuard implements StatefulGuard
{
    protected $client;

    public function __construct(
        string $name,
        CognitoClient $client,
        UserProvider $provider,
        Session $session,
        ?Request $request = null
    ) {
        $this->client = $client;
        parent::__construct($name, $provider, $session, $request);
    }

    protected function hasValidCredentials($user, $credentials)
    {
        $result = $this->client->authenticate($credentials['email'], $credentials['password']);
        if ($result && $user instanceof Authenticatable) {
            return true;
        }
        return false;
    }

    public function attempt(array $credentials = [], $remember = false)
    {
        $this->fireAttemptEvent($credentials, $remember);
        $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);
        if ($this->hasValidCredentials($user, $credentials)) {
            $this->login($user, $remember);
            return true;
        }
        $this->fireFailedEvent($user, $credentials);
        return false;
    }
}
```

アプリケーションのガードを変更します。

```（抜粋）config\auth.php
    'guards' => [
        'web' => [
            'driver' => 'cognito',
            'provider' => 'users',
        ],
        // ...
    ],
```

最後に、コントローラをカスタマイズします。

```（抜粋）app\Http\Controllers\Auth\LoginController.php
    public function login(Request $request)
    {
        $this->validateLogin($request);
        if ($this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);
            return $this->sendLockoutResponse($request);
        }
        try {
            if ($this->attemptLogin($request)) {
                return $this->sendLoginResponse($request);
            }
        } catch (CognitoIdentityProviderException $ce) {
            return $this->sendFailedCognitoResponse($ce);
        } catch (\Exception $e) {
            return $this->sendFailedLoginResponse($request);
        }
        return $this->sendFailedLoginResponse($request);
    }
```

アレ？できない！？ とならないことを祈ります。アーメン