狛ログ

2022年1月11日火曜日

【Laravel】Jetstream + Fortify を画面無し(APIとして)で運用する。


オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

Webシステムにおける認証機能、重要ですが、作成するの大変ですよね・・・・。
その悩みを解決するのがLaravelでは、「Jetstream + Fortify」な訳ですが、今回、「フロントとバックエンドを完全に分離したいので、認証機能はAPIで呼び出す方式にして欲しい」との要望があり、Jetstream + Fortifyを画面無し(APIとして)で実装することになったので、記録に残しておこうと思います。

(1)各バージョンと前提

まずは、バージョンですが、
・Laravel 8.27.0
・PHP 8.0.9
となります。

そして、前提条件ですが、
Laravel Jetstreamの設定が完了していて、画面を経由してのログインなどは出来ている
とします。

(2)Fortify設定値変更

早速対応方法としては・・・・Fortifyの設定値の変更となり、
[config/fortify.php]
    // Viewを使用しない場合、trueからfalseに変更
    'views' => false,
で、完了、以上。
・・・・としたいところなのですが、そうも行きません。

(3)パスワードリセットのルート定義追加

まずは、本家サイトにも記載がありますが、パスワードリセットのルートは定義する必要があります。
[routes/web.php]
/*
| FortifyのViewを使用しない場合でも、「password.reset」のルートは定義する必要がある。
| https://laravel.com/docs/8.x/fortify#disabling-views-and-password-reset
*/
Route::get('/reset-password/{token}', ResetPasswordController::class)
    ->name('password.reset');

ResetPasswordControllerは新規で作成します
[app/Http/Controllers/ResetPasswordController.php](新規作成)
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;

class ResetPasswordController extends Controller
{
    public function __invoke(Request $request)
    {
        // メールアドレスとトークンを返却する
        $result = [
            'email' => $request->email,
            'token' => $request->token
        ];
        return response()->json($result);
    }
}

さて、ここで疑問なのですが、なぜ、ルート定義する必要があるのでしょうか?
それを説明する為に、LaravelとFortifyの中を覗いてみましょう
[vendor/laravel/fortify/routes/routes.php](抜粋)
    if (Features::enabled(Features::resetPasswords())) {
        if ($enableViews) {
            Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])
                ->middleware(['guest'])
                ->name('password.request');

            Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])
                ->middleware(['guest'])
                ->name('password.reset');
        }

        Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
            ->middleware(['guest'])
            ->name('password.email');

        Route::post('/reset-password', [NewPasswordController::class, 'store'])
            ->middleware(['guest'])
            ->name('password.update');
    }
抜粋して記載していますが、問題の「password.reset」、ありますね。
ただ、よく見てみると、「if ($enableViews) {」となっているので、Viewが無効になっていると定義されない、と言うことが分かります。

なるほど、だから、自分達で定義する必要があると。では、なぜ定義が必要なのか、さらに見てみると、
[vendor/laravel/framework/src/Illuminate/Auth/Notifications/ResetPassword.php](抜粋)
    public function toMail($notifiable)
    {
        if (static::$toMailCallback) {
            return call_user_func(static::$toMailCallback, $notifiable, $this->token);
        }

        if (static::$createUrlCallback) {
            $url = call_user_func(static::$createUrlCallback, $notifiable, $this->token);
        } else {
            $url = url(route('password.reset', [
                'token' => $this->token,
                'email' => $notifiable->getEmailForPasswordReset(),
            ], false));
        }

        return $this->buildMailMessage($url);
    }
これは、パスワードリセットをした時にメールを送る処理ですね。
ここで「password.reset」のルート定義を使ってURLを作っている訳です。
なので、定義は必要になる、ということですね。

これで、今度こそ画面無し(API化)完了・・・・とならないんですね、これが。

(4)未認証・認証済みの処理追加

ちょっと微妙な調整が必要になります。
そもそもの動きとして、画面有りのJetstream + Fortifyでは、
・ログイン状態で、ログイン画面に遷移しようとすると、ホーム画面へリダイレクトする
・未ログイン状態で、ログインが必要な画面に遷移すると、ログイン画面へリダイレクトする
という動きになります。

実はこの動き、
[config/fortify.php]
    // Viewを使用しない場合、trueからfalseに変更
    'views' => false,
の設定をしても変わりません。

API化した状態で考えると、
・バックエンド側でログイン状態(認証済み)の時に、フロントからログインAPIなど未ログイン状態が前提のAPIを呼ばれると、ホーム画面へのリダイレクト指示をレスポンスする
・バックエンド側で未ログイン状態(未認証)の時に、ログイン済み必須なAPIを呼ばれると、ログイン画面へのリダイレクト指示をレスポンスする
となってしまいます。

これは何とかしたいですね。

という事で、まずは前者の
認証済みの時に、ログインAPIなど未ログイン状態が前提のAPIを呼んだ場合、です。

この場合、MiddlewareのRedirectIfAuthenticated.phpに処理が書かれています。
[app/Http/Middleware/RedirectIfAuthenticated.php]
    public function handle(Request $request, Closure $next, ...$guards)
    {
        $guards = empty($guards) ? [null] : $guards;

        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                return redirect(RouteServiceProvider::HOME);
            }
        }

        return $next($request);
    }
なるほど、確かにホーム画面にリダイレクトしていますね。
では、早速修正して・・・・・
[app/Http/Middleware/RedirectIfAuthenticated.php](修正後)
    public function handle(Request $request, Closure $next, ...$guards)
    {
        $guards = empty($guards) ? [null] : $guards;
        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                if ($request->expectsJson()) {
                    // クライアントからJSONレスポンスを要求されている場合、リダイレクトさせず、JSON形式メッセージをレスポンスする。
                    return response()->json(['message' => 'Already authenticated.'], 200);
                }
                return redirect(RouteServiceProvider::HOME);
            }
        }

        return $next($request);
    }
と、こんな感じで、APIとして呼ばれた場合、「Already authenticated.」をレスポンスするようにしました。
後は、フロント側で良きに計らってもらうようにしましょう。

次は、
未認証の時に、ログイン済み必須なAPIを呼ばれた場合、です。
この場合、MiddlewareのAuthenticate.phpに処理が書かれています。
[app/Http/Middleware/Authenticate.php]
    protected function redirectTo($request)
    {
        if (! $request->expectsJson()) {
            return route('login');
        }
    }
あ、でも、これを見る限り、APIとして呼ばれた場合は、ログイン画面のリダイレクトはレスポンスされない気がしますね。
何もレスポンスされないのも困ってしまうので、こちらは、
[app/Http/Middleware/Authenticate.php](修正後)
    protected function redirectTo($request)
    {
        return $request->expectsJson()
            ? response()->json(['message' => 'Unauthenticated.'], 401)
            : route('login');
    }
このように、401エラーとして、Unauthenticated.を返却するようにしました。
こちらも、後は、フロント側で良きに計らってもらうようにしましょう。

ということで、API化完了となります。

細かい部分はまだまだやる事(Viewを消したり、等々)があるのですが、今回はこのぐらいで十分かと思います。

LaravelのJetstream、Fortifyはかなり便利なので、どんどん使っていきたいですね。


【AWS】API GatewayからS3に配置したファイルを直接レスポンスする。

オフィス狛 技術部のyuckieeeです。
今回もまた「API Gateway」を使用したAPI用Mockに関する小ネタをご紹介しようと思います。

本投稿の背景

API Gatewayで快適Mockライフを送る予定だったのですが、思わぬ壁にぶつかりました。
何かというと、データ量の多いJSONデータ(2~6MB程度)を返却しようと統合Mockを設定したところ見知らぬエラーが表示されたのです。
※そんなデータ、JSONで返却するなよ...というツッコミはとりあえず置いておいて下さい(笑)

エラーメッセージ:The resource being saved is too large.Consider reducing the number of modeled parameters, the number of response mappings, or perhaps the size of your VTL templates if used. 何だコレ...と思っていたのですが、どうやらAPI Gatewayにはマッピングテンプレートに設定容量制限があり、そこに引っかかったようです。
私の環境では、マッピングテンプレート128KB辺りを境界にエラーが発生しているようでしたが、公式ページ上のAPIGatewayの制限事項にドンピシャの記述は見つけられませんでした。
ですが、恐らく、この中の何かの制限に引っかかっているものと思います。
公式:REST API の設定および実行に関する API Gateway クォータ

それじゃあ、サイズの大きいデータはどうやって返せば良いのか...
Lambdaを使用することも可能ですが、出来るだけ簡単に対応したいと思ってAPI Gatewayを使用してMockを作成したのに構成が複雑になったら使う意味g(以下略
調査の結果「S3に配置したJSONファイルを直接返却する方法」が一番簡単そうでした。
実際に試すと6MBまでのデータであれば対応可能で、今回の私の要件は満たせそうです。

ということで、 今回はAPI GatewayからS3に直接アクセスしてJSONを返却する方法をご紹介します!

設定手順

以下の作成が完了している前提で、説明割愛いたします。
・S3バケットの作成
・API Gatewayで任意の統合Mock作成(→詳細はコチラで紹介しています!)

Step1: IAMロール作成

デフォルトの状態ではAPI GatewayにS3へのアクセス権限は付与されていません。
そのため、API GatewayにS3へのアクセス許可を与えるためのロールを作成します。

AWSコンソールからIAMの管理画面を開きます。
アクセス管理のロールを選択し、表示された画面右上にある[ロールを作成]を選択して下さい。
[ロールの作成]画面が表示されたら、[信頼されたエンティティの種類を選択]で「AWSサービス」が選択されていることを確認します。
[ユースケースの選択]で[S3(一覧)]>[S3(画面下部)]を選択し、[次のステップ:アクセス権限]ボタンをクリックします。
※今回は、信頼されたエンティティとして「API Gateway」を設定したいのですが、「API Gateway」として用意されているユースケースは、API GatewayからCloud watchにログをアップするというもので、S3へアクセスするユースケースは用意されていないようでした。そのため、本ユースケース設定をベースにカスタマイズします。

次画面の[Attach アクセス権限ポリシー]で、一覧から「AmazonS3ReadOnlyAccess」(※)を選択し、[次のステップ:タグ]ボタンをクリックしてください。
※今回はMockとして使用するため、リクエストがPOSTやPUTであっても、S3上のオブジェクトを読み込みレスポンスとして返却するのみです。そのため、必要最低限である読み取り権限のみのポリシーを選択しています。
タグの追加画面では特に設定変更を行わず[次のステップ:確認画面]をクリックし、確認画面まで遷移してください。
確認画面では以下2項目を設定し、[ロールの作成]ボタンをクリックすれば、ロールが作成されます。

【設定値】
・ロール名:任意
・ロールの説明:任意 ※今回だと「Allows API Gateway to access to S3.」とかでしょうか。

Step2: IAMロールのカスタマイズ

ロールを作成した段階では、ユースケースで選択したとおり「Allows S3 to call AWS services on your behalf.」という、S3が、このロールを使用して任意のAWSサービス(今回はS3)を呼び出すことを想定したものとなっています。
そのため、信頼関係を変更することで「S3」ではなく「API Gateway」が、このロールを使用できるようにします。

作成したIAMロールの概要ページに遷移し、[信頼関係]タブの[信頼関係の編集]ボタンをクリックして信頼関係の編集画面を開きます。
7行目の"Service": "s3.amazonaws.com"の記述を"Service": "apigateway.amazonaws.com"に書き換え、[信頼ポリシーの更新]ボタンをクリックして信頼関係を変更します。
これでAWSサービス「API Gateway」が、該当ロールとして振る舞うことを許可(信頼)されました。

信頼関係の変更後、IAMロールの設定状態を確認します。

[アクセス権限]タブ
「AmazonS3ReadOnlyAccess」権限のみが付与されていることを確認します。
[信頼関係]タブ
信頼されたエンティティに「IDプロバイダー apigateway.amazonaws.com」が設定されていることを確認します。

これで今回使用するロールの作成が完了しました。

Step3:S3にレスポンスファイルを配置する

S3バケットを任意で作成後、管理コンソールからレスポンス用のファイルをアップロードします。
今回は、JSON形式で返却したいため、以下のようなファイルを準備しました。
    {      "result_code" : "0",     "result_message" : "OK"     }

Step4:API Gatatewayの設定を変更する

S3オブジェクトによるレスポンスに変更したい、API Gateway上のリソースを選択します。
[メソッドの実行]画面で[統合リクエスト]をクリックし、設定画面で以下のように設定を変更して[保存]をクリクします。

【設定値】
 ・統合タイプ:AWSサービス
 ・AWSリージョン:ap-notheast-1
 ・AWSサービス:Simpe Storage Service(S3)
 ・AWSサブドメイン:指定なし
 ・HTTPメソッド:GET 
 ・アクションの種類:[パス上書きの使用]にチェック
 ・パス上書き:レスポンス用ファイルのパス ※バケット名/オブジェクト名
 ・実行ロール:今回用に作成したロールを指定(arn:aws:iam::xxxxx)
 ・コンテンツの処理:パススルー※デフォルト設定
 ・デフォルトタイムアウトの使用:チェックオン※デフォルト設定
AWSの統合に切り替えてよいかの確認ダイアログが表示されますので、[OK]をクリックしてください。
これで設定変更は完了です。

Step5: 動作確認&APIのデプロイ

実際に動くかどうか確認を行います。
[メソッドの実行]画面で画面右にある[テスト⚡]をクリックし、[メソッドテスト]画面に遷移します。
[メソッドテスト]画面の左下にある[⚡テスト]ボタンをクリックして、テスト実行してください。
リクエストのレスポンス本文にS3オブジェクトに配置したとおりの内容が返ってきていればOKです。

ここまで確認ができれば、APIのデプロイを行い、作業終了です。
実際にMockとして呼び出して、テストしてみてください。

おまけ:レスポンスに画像を指定する

JSONだけではなく画像ファイルを返却したいという場合もあると思います。
今の設定でも、S3上のファイルを画像ファイルに変えれば上手くいくんじゃないの?って感じるかもしれませんが、デフォルトで画像ファイルのレスポンスは制限されているようです。
以下設定手順を軽くご紹介しておきます。

設定手順

該当APIの設定メニューを選択し、設定画面を開きます。
設定画面の下部にある「バイナリメディアイプ」の[+バイナリメディアタイプの追加]を押下することで、入力フィールドが追加されるため、「*/*」(※)を入力して、右下の[変更の保存]を押下して完了です。
※指定する内容は、HTTPヘッダーのContent-Typeと同様の内容でも問題ありませんが、ワイルドカードで指定しておくのが一番楽ちんかと思います。
これでS3の画像オブジェクトを指定しても、問題なくレスポンスできるようになったかと思います。

まとめ

こちらの方が従来のMockツールに近い感じですね。
また、S3のファイルを置き換えれば、レスポンス内容変更のたびにAPIをデプロイし直すという手間も省けて楽な感じがします。
ガッツリテストをするとなると、こちらの方が向いているかな...というのが個人的な感想です。

API Gatewayには、いろいろな使い方があるのがわかってきたので、もっと色々と紹介していきたいと思います!


, ,