Laravelで Amazon Cognito ログイン認証1【ログイン機能】

世の中のログイン機能の大半を占めているOAuthやOIDCの仕組みですが、わかっているようでイマイチ理解していないなと思っておりました。

最近業務でログイン機能について触れる機会があり、これを機に実際に実装しながら理解を深めようと思い立ちまして情報を整理していきます。

今回はAWSの認証サービスであるAmazon Cognitoを利用して実装を進めていきます。

■やりたいこと

Cognitoを利用したログイン機能を実現する

ログイン機能をカスタマイズする

Cognitoでの会員登録情報を連携する

■参考URL

https://qiita.com/tomoeine/items/40a966bf3801633cf90f

Laravelでログイン認証シリーズ

  1. Laravelで Amazon Cognito ログイン認証1【ログイン機能】 ← イマココ
  2. Laravelで Amazon Cognito ログイン認証2【会員登録機能】

完成系の画面フロー

Cognito Laravelログイン画面フロー

いくつかやり方はありますが、今回はAmazon Cognitoのログイン画面を利用して実現する方法です。

LaravelからAmazon Cognitoで用意されている認可エンドポイントへアクセスし、認可コードを取得します。

※ホストされたWEB UI(AWS側で準備されたログイン画面)を利用しています。この画面は日本語化できません。
大事なことなのでもう一度言います。日本語化できません。

Amazon Cognitoの設定

Cognitoには「ユーザプール」と「IDプール」という2つの管理があります。それぞれの違いは下記の通りです。今回は一般ユーザ向けなので「ユーザプール」を利用します。

  • ユーザプール:会員サイトなどでユーザのアカウントを管理する場合に利用
  • IDプール:組織のアカウント管理などに利用

ユーザプールの設定

今回はステップに従って細かく設定していきます。

デフォルトを確認しながら進めて頂いても問題ございません。

Cognitoユーザプールの設定

まずはユーザのサインイン方法です。今回はメールアドレスでログインさせるため「Eメール」の設定を選択します。

他にもユーザ名や電話番号での設定も可能です。

Cognitoユーザプールの設定

次にパスワード強度と自己サインアップの設定です。

パスワード強度はデフォルトのままで問題ありません。セキュリティポリシーなどがある場合は、ポリシーに従って設定をします。

自己サインアップはデフォルトで許可されているのでこのまま登録します。会員制サイトなどで管理者にのみユーザ作成の権限を与える場合には設定を変更します。

Cognitoユーザプールの設定 パスワード強度など

多要素認証の設定です。Cognitoでは簡単に多要素認証が実現できます。

今回はログイン認証のテストのみのため、設定はオフにします。後から設定を変えで多要素認証を利用することも可能です。

Cognitoユーザプールの設定 多要素認証

こちらはメールアドレス検証のメール送信時の設定です。fromメールアドレスを変更することができます。

Amazon SESを経由してメールを送信することができますが、今回は利用しておりません。

キャプチャしきれておりませんが、送信するメール本文のカスタマイズなどもこちらの設定で可能です。

Cognitoメールアドレス認証の設定

アプリクライアントの設定です。アプリクライアント名はログイン機能を実装する際にコードとして利用します。

その他の設定はデフォルトのままでも問題ございません。

Cognitoアプリクライアントの設定

今回は認証フローとして更新トークンベースの認証を利用します。デフォルトでチェック済みです。

他にもデフォルトでチェックされているものがありますが、今回のフローでは利用しないので外しても問題ございません。

Cognito認証フローの設定

これでCognitoの設定は完了です。

会員準備

設定が完了したら、先に検証用のユーザを作成しておきます。このユーザでLaravelとCognitoの認証連携の検証を行います。

会員登録は「ユーザとグループ」の設定から行います。

Cognito会員登録

ユーザの作成から進んでいくと、下記の画面が表示されます。今回はメールアドレスとパスワードのみでログインできるようにするため、電話番号の入力は不要です。

ユーザ名も利用しないのでとりあえずメールアドレスを入れておけば大丈夫です。

Cognito会員登録

メールアドレスの重複チェックはデフォルトで実施されています

ユーザの作成を押下するとメールアドレスに検証コードが送付されますので、コードを入力して認証を行います。

「Eめーるあどれすを検証済みにしますか?」のチェックを入れておくとこの作業は不要になります。

Cognito会員登録 メールアドレス認証
Cognito会員登録 メールアドレス認証パスコード

これで検証用アカウントの準備も完了しました。

この後のログイン機能の検証で利用しますので、パスワードを忘れないように控えておきましょう。

Laravelでのログイン機能実装

Laravelでは認証関連の機能が一通り用意されています。今回の検証もこの機能をベースに利用していきます。

php artisan make:auth

これで認証機能を使う準備ができました。

Controllerや画面viewなど一通りのソースが作成されています。

Laravelでの認証機能おさらい

実装を始めるにLaravelでのログイン機能をおさらいいたします。

ポイントは2点

Guard

認証にかかわるユースケースや認証方法が定義されています。

認証の方法ごとにクラスが準備されており、よく使うのは SessionGuard というクラスです。

src/Illuminate/Auth/SessionGuard.php

多数の処理が記述されていますが、重要なメソッドだけピックアップして解説します。

メソッド解説
attempt認証情報をパラメータとして受け取り認証を試みます。認証によりアカウントを特定する処理は後述のProviderが行います。
認証対象のユーザを特定したあと、認証後処理は login メソッドを呼び出します。
loginユーザ情報を受け取り認証後の処理を行います。具体的にはセッションを張りログイン状態を維持できるようにします。
userログイン済みのユーザ情報を取得します。Guardインスタンスからユーザ情報が取得できなかった場合は、セッションからの取得を試みます。
ログインが必要なページにアクセスした際などに呼ばれているようです。

Provider

認証のメイン処理が記述されています。Laravel認証の心臓部です。

src/Illuminate/Auth/EloquentUserProvider.php

この内部処理を確認することはほとんどないと思いますので、説明は割愛します。

参考サイト

https://qiita.com/tomoeine/items/40a966bf3801633cf90f

Configファイルの設定

実装を行っていく前にGuardとProviderがconfigファイル上のどこで設定されているか確認・変更していきます。

設定が記載されているのはconfigディレクトリのauth.phpになります。

    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

     'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

デフォルトで利用するGuardが指定され、Guardの中で利用するdriverとproviderが指定されています。

今回はここに新たにcognitoというGuardを追加していきます。

    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],


        'cognito' => [
            'driver' => 'cognito',
            'provider' => 'users',
            'hash' => false,
        ],
    ],

driverの設定はGuardの名称のようなものです。Guardのカスタムクラスを作成する際に「driverの名称」+「Guard」というクラス名にする必要があります。

今回はcognitoというdriver名にしているので、GuardのカスタムクラスはCognitoGuardで作成します。こちらは後ほど。

cognito連携

cognito連携用のクラスを作成します。

必要なコンポーネントはSDKをインストールして利用させてもらいます。

composer require aws/aws-sdk-php

Cognito連携用のクラスはこちらになります。

<?php

namespace App\Cognito;
use Aws\CognitoIdentity\Exception\CognitoIdentityException;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;


class CognitoClient
{

    protected $client;
    protected $clientId;
    protected $poolId;
    protected $authorization;

    public function __construct(CognitoIdentityProviderClient $client)
    {

        $this->client       = $client;
        $this->clientId     = config('cognito.clientId');
        $this->poolId       = config('cognito.poolId');
        $this->authorization = base64_encode(config('cognito.clientId').':'.config('clientId.clientSecret'));

    }

    public function fetchTokenEndpoint($code,$code_challenge){

        try{
            $response = Http::asForm()
                ->contentType('application/x-www-form-urlencoded')
                ->retry(3, 100)
                ->post(config('cognito.token_endpoint'), [
                    'client_id' => $this->getClientId(),
                    'grant_type' => 'authorization_code',
                    'code' =>  $code,           // 必須 認可エンドポイントのレスポンスに含まれる値を指定
                    'redirect_uri' => config('cognito.callback_uri'),          // 認可リクエストに redirect_uri が含まれていれば必須
                    'client_secret' => config('cognito.clientSecret'),
                ]);

            if(!$response->successful()){
                throw new \Exception('status code error:'.$response->status());
            }

        }catch (CognitoIdentityException $e){

            return null;
        }catch (\Exception $e){

            return null;
        }

        return json_decode($response);
    }

    public function getClientId(){
        return $this->clientId;
    }

}


ポイント Http::asForm()

content typeを「x-www-form-urlencoded」としてリクエストする場合、LaravelではasFormを利用する必要があります。

フォームURLエンコードされたリクエストの送信

application/x-www-form-urlencodedコンテンツタイプを使用してデータを送信する場合は、リクエストを行う前にasFormメソッドを呼び出す必要があります。

Laravel 8.x HTTPクライアント

必要な設定情報等はconfigファイルにまとめました。新規で「cognito」用の設定ファイルを作成しています。

<?php
return  [

    'clientId' => '{clientId}',
    'clientSecret' => '{clientSecret}',
    'poolId' => 'ap-northeast-1_{your pool id}',
    'region' =>  'ap-northeast-1',
    'version' =>  'latest',
    'endpoint' => 'https://{your domain}.auth.ap-northeast-1.amazoncognito.com/',
    'token_endpoint' => 'https://{your domain}.auth.ap-northeast-1.amazoncognito.com/oauth2/token',
    'callback_uri' => 'http://localhost:8000/login_cognito/callback/',
    'jwk' => 'https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_EAVdUIDWB/.well-known/jwks.json',

];

clientIdなどはご自身の環境に応じて適宜設定ください。

コールバックURIは開発用にローカルホストに設定しています。これはCognitoの設定画面に登録したものと同じ設定にする必要があります。

jwt検証用クラス作成

jwtの検証のため、firebase/php-jwtを利用させて頂きます。

composer require firebase/php-jwt

jwtの検証はこのように実装しています。

<?php

namespace App\Services;

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

class JWTVerifier
{
    /**
     * @param string $jwt
     * @return object|null
     * @throws Exception
     */
    public function decode(string $jwt)
    {
        $tks = explode('.', $jwt);
        $header = $tks[0];
        $jwks = $this->fetchJWKs();

        try {
            $kid = $this->getKid($header); //jwtからkidを取得
            $alg = $this->getAlg($jwks, $kid);//署名アルゴリズムを取得
            $key = $this->getKey($jwks, $kid,$alg);//jwkからdecode用のkeyを取得

            return JWT::decode($jwt, $key);
        }catch (UnexpectedValueException $e){
            throw new Exception('パラメータ不正');
            return null;
        }catch (Exception $exception) {
            throw $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 getKey(array $jwks, string $kid,string $alg)
    {
        $keys = JWK::parseKeySet($jwks,$alg);
        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 fetchJWKs(): array
    {
        $response = HTTP::get(config('cognito.jwk'));
        return json_decode($response->body(), true) ?: [];
    }
}

Guardの作成

GuardはSessionGuardを参考に機能拡張していきます。

まずは参考とするSessionGuardから解説です。

    public function __construct($name,
                                UserProvider $provider,
                                Session $session,
                                Request $request = null)
    {
        $this->name = $name;
        $this->session = $session;
        $this->request = $request;
        $this->provider = $provider;
    }


    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;
        }


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

        if (! is_null($this->user)) {
            return $this->user;
        }

        $id = $this->session->get($this->getName());

        if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
            $this->fireAuthenticatedEvent($this->user);
        }

        if (is_null($this->user) && ! is_null($recaller = $this->recaller())) {
            $this->user = $this->userFromRecaller($recaller);

            if ($this->user) {
                $this->updateSession($this->user->getAuthIdentifier());

                $this->fireLoginEvent($this->user, true);
            }
        }

        return $this->user;
    }


Cognitoを利用するGuardの作成

<?php

namespace App\Services;

use Illuminate\Auth\GuardHelpers;
use Illuminate\Auth\SessionGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Support\Facades\Log;
use Illuminate\Contracts\Session\Session;

class CognitoGuard extends SessionGuard
{
    use GuardHelpers;
    public $name;
    private $jwt_verifier;
    protected $provider;
    protected $session;

    public function __construct($name,JWTVerifier $JWTVerifier,Session $session, UserProvider $userProvider)
    {
        $this->name = $name;
        $this->jwt_verifier = $JWTVerifier;
        $this->session = $session;
        $this->provider = $userProvider;

    }

    public function attempt(array $credentials = [], $remember = false)
    {
        $decoded_jwt = $this->jwt_verifier->decode($credentials['id_token']);
        $this->fireAttemptEvent(['email' => $decoded_jwt->email], $remember);
        $this->lastAttempted = $user = $this->provider->retrieveByCredentials(['email' => $decoded_jwt->email]);
        $this->login($user, $remember);

        return true;
    }
}

UserProviderはconfigに設定されたものが依存性注入されて利用されています。

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
    ],

eloquentと記述されています。これがまさにProviderの説明で登場したEloquentUserProviderです。

src/Illuminate/Auth/EloquentUserProvider.php

サービスプロバイダの設定

リクエスト処理に認証判定を挟み込む必要があるため、サービスプロバイダに設定します。

認証セッションを持った状態でアクセスされた場合に自動ログインを挟むフローを実現できます。

CognitoGuardの呼び出し

サービスプロバイダからCognitoGuardのインスタンスを作成して認証処理を行います。

<?php

namespace App\Providers;

use App\Services\CognitoGuard;
use App\Services\JWTVerifier;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;

class AuthServiceProvider extends ServiceProvider
{

    protected $policies = [
    ];


    public function boot()
    {
        $this->registerPolicies();

        Auth::extend('cognito', function($app, $name, array $config){
            return new CognitoGuard(
                config('auth.defaults.guards'),
                new JWTVerifier(),
                $app['session.store'],
                Auth::createUserProvider($config['provider'])
            );
        });
    }
}

controllerの実装

最後にcontrollerの設定です。

デフォルトで作成されているLoginControllerとは別にCognito用LoginControllerを作成しています。

<?php

namespace App\Http\Controllers;

use App\Cognito\CognitoClient;
use App\Providers\RouteServiceProvider;
use App\Traits\EncryptTrait;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;

class LoginCognitoController extends Controller
{
    use EncryptTrait;
    use AuthenticatesUsers;

    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }


    protected $redirectTo = RouteServiceProvider::HOME;


    public function index()
    {
        $callback_uri = config('cognito.callback_uri');

        $config = [
            'region'      => config('cognito.region'),
            'version'     => config('cognito.version')
        ];
        $client = new CognitoClient(
            new CognitoIdentityProviderClient($config)
        );

        $query_param = 'response_type=code';
        $query_param .= '&amp;client_id='.$client->getClientId();
        $query_param .= '&amp;redirect_uri='.$callback_uri;
        $query_param .= '&amp;state='.'authorization';

        return redirect(config('cognito.endpoint').'login?'.$query_param);
    }

    public function callback(Request $request){
        $state = $request->input('state');

        $code_challenge = $request->input('code_challenge');
        $code = $request->input('code');
        //アクセストークンの取得
        if($state == 'authorization' && !empty($code)){
            $tokens = $this->fetchToken($code,$code_challenge);
            $credentials['id_token'] = $tokens->id_token;
            $credentials['access_token'] = $tokens->access_token;
            $credentials['refresh_token'] = $tokens->refresh_token;
            Auth::guard('cognito')->attempt($credentials);
        }

        if(empty($redirect_url)){
            return redirect('/home');
        }else{
            return redirect('/');
        }
    }

    private function fetchToken($code,$code_challenge){
        Log::info(self::class.' '.__FUNCTION__);

        $config = [
            'region'      => config('cognito.region'),
            'version'     => config('cognito.version')
        ];
        $client = new CognitoClient(
            new CognitoIdentityProviderClient($config)
        );
        return $client->fetchTokenEndpoint($code,$code_challenge);
    }
}

indexメソッド

ログイン画面のURLがコールされた際の処理です。Cognitoのログイン画面へアクセスするURLを生成しリダイレクトしています。

クエリパラメータ
response_typecodeまたはtokenと指定
今回のケースは認可コードフローなのでcodeを指定
client_idConito管理画面から確認できるクライアントID
redirect_uriコールバックのURLを指定
stateCSRF対策のパラメータ。今回のサンプルでは文字列を暗号化してそのまま渡しています。
Cognito認可エンドポイント

callbackメソッド

レスポンスパラメータに含まれる「code」を取得してアクセストークンの処理を行います。

トークンエンドポイントへのアクセスはCognitoClientクラスで処理しています。

詳細はAWS公式のマニュアルをご覧ください

トークンエンドポイント

まとめ

Laravelを利用した認証機能はとても便利ですが、サイト間SSOやMFAの実現しようとすると結構ハードルが高いです。

Amazon Cognitoなどの外部の認証サービスを使えばこれらの機能が簡単に導入できますので、本来のアプリケーション開発に専念することができます。

会員サイトなどを構築する場合には確実に必要になる認証機能ですので、導入案の一つとして検討した方がいいですね。

コメントを残す