狛ログ

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はかなり便利なので、どんどん使っていきたいですね。


0 件のコメント:

コメントを投稿