【実装編】Laravel 8（+jetstream, fortify）でマルチログイン

# はじめに
この記事ではLaravel8でマルチログインの実装をしていきます。
動作環境、詳しい仕様は[前回の記事](https://qiita.com/nasteng/items/c6c026c3448a07a7fd15)をご覧ください。
前回でUserのログイン機能とAdminモデル、テーブルの作成まで完了しました。
なお、本プロジェクトはGitHubにアップしてありますので[ご参考](https://github.com/nasteng/multi-auth)ください。


# マルチログイン実装
- 一度ルーティングを確認してみる
    - `php artisan route:list`でルーティングを確認してみると、すでにUserの認証に必要なルーティングが設定されています。これはLaravel Fortify内部の/{your-workspace-root}/multi-auth/vendor/laravel/fortify/routes/routes.phpで定義されています。Userの認証関係のルーティングはこちらに全て任せることにしましょう。
    - login/logout処理に関してはAuthenticatedSessionControllerが担っていますので、Adminの認証もこちらを参考にしていきます。

```
+--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+---------------------------------------------------------+
| Domain | Method   | URI                              | Name                            | Action                                                                          | Middleware                                              |
+--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+---------------------------------------------------------+
|        | POST     | login                            |                                 | Laravel\Fortify\Http\Controllers\AuthenticatedSessionController@store           | App\Http\Middleware\EncryptCookies                      |
|        |          |                                  |                                 |                                                                                 | Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse |
|        |          |                                  |                                 |                                                                                 | Illuminate\Session\Middleware\StartSession              |
|        |          |                                  |                                 |                                                                                 | Laravel\Jetstream\Http\Middleware\AuthenticateSession   |
|        |          |                                  |                                 |                                                                                 | Illuminate\View\Middleware\ShareErrorsFromSession       |
|        |          |                                  |                                 |                                                                                 | App\Http\Middleware\VerifyCsrfToken                     |
|        |          |                                  |                                 |                                                                                 | Illuminate\Routing\Middleware\SubstituteBindings        |
|        |          |                                  |                                 |                                                                                 | App\Http\Middleware\RedirectIfAuthenticated             |
|        | GET|HEAD | login                            | login                           | Laravel\Fortify\Http\Controllers\AuthenticatedSessionController@create          | App\Http\Middleware\EncryptCookies                      |
```

```PHP:multi-auth/vendor/laravel/fortify/routes/routes.php
Route::group(['middleware' => config('fortify.middleware', ['web'])], function () {
    // Authentication...
    Route::get('/login', [AuthenticatedSessionController::class, 'create'])
        ->middleware(['guest'])
        ->name('login');

    $limiter = config('fortify.limiters.login');

    Route::post('/login', [AuthenticatedSessionController::class, 'store'])
        ->middleware(array_filter([
            'guest',
            $limiter ? 'throttle:'.$limiter : null,
        ]));

    Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])
        ->name('logout');
~~省略~~
```

```PHP:multi-auth/vendor/laravel/fortify/src/Http/Controllers/AuthenticatedSessionController.php
<?php

namespace Laravel\Fortify\Http\Controllers;

use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Routing\Pipeline;
use Laravel\Fortify\Actions\AttemptToAuthenticate;
use Laravel\Fortify\Actions\EnsureLoginIsNotThrottled;
use Laravel\Fortify\Actions\PrepareAuthenticatedSession;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\LoginViewResponse;
use Laravel\Fortify\Contracts\LogoutResponse;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Requests\LoginRequest;

class AuthenticatedSessionController extends Controller
{
    /**
     * The guard implementation.
     *
     * @var \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected $guard;

    /**
     * Create a new controller instance.
     *
     * @param  \Illuminate\Contracts\Auth\StatefulGuard
     * @return void
     */
    public function __construct(StatefulGuard $guard)
    {
        $this->guard = $guard;
    }

    /**
     * Show the login view.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Laravel\Fortify\Contracts\LoginViewResponse
     */
    public function create(Request $request): LoginViewResponse
    {
        return app(LoginViewResponse::class);
    }

    /**
     * Attempt to authenticate a new session.
     *
     * @param  \Laravel\Fortify\Http\Requests\LoginRequest  $request
     * @return mixed
     */
    public function store(LoginRequest $request)
    {
        return $this->loginPipeline($request)->then(function ($request) {
            return app(LoginResponse::class);
        });
    }

    /**
     * Get the authentication pipeline instance.
     *
     * @param  \Laravel\Fortify\Http\Requests\LoginRequest  $request
     * @return \Illuminate\Pipeline\Pipeline
     */
    protected function loginPipeline(LoginRequest $request)
    {
        if (Fortify::$authenticateThroughCallback) {
            return (new Pipeline(app()))->send($request)->through(array_filter(
                call_user_func(Fortify::$authenticateThroughCallback, $request)
            ));
        }

        if (is_array(config('fortify.pipelines.login'))) {
            return (new Pipeline(app()))->send($request)->through(array_filter(
                config('fortify.pipelines.login')
            ));
        }

        return (new Pipeline(app()))->send($request)->through(array_filter([
            config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,
            RedirectIfTwoFactorAuthenticatable::class,
            AttemptToAuthenticate::class,
            PrepareAuthenticatedSession::class,
        ]));
    }

    /**
     * Destroy an authenticated session.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Laravel\Fortify\Contracts\LogoutResponse
     */
    public function destroy(Request $request): LogoutResponse
    {
        $this->guard->logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return app(LogoutResponse::class);
    }
}
```

基本的にFortifyの各クラスを参考に実装していきます。それでは作成していきましょう。

### AdminLoginResponseクラスの作成
- multi-auth/app/Responsesディレクトリを作成し、その中にAdminLoginResponseを作成
    - AdminLoginResponseは/{your-workspace-root}/multi-auth/vendor/laravel/fortify/src/Http/Responses/LoginResponse.phpからコピペして一部修正します。

```PHP:muti-auth/app/Responses/AdminLoginResponse.php
<?php

namespace App\Responses;

use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;

class AdminLoginResponse implements LoginResponseContract
{
    /**
     * Create an HTTP response that represents the object.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function toResponse($request)
    {
        return $request->wantsJson()
            ? response()->json(['two_factor' => false])
            : redirect()->intended('admin/dashboard'); // ログイン後に遷移させたいリダイレクト先を指定
    }
}
```

### login.blade.phpの修正
- Userと同じBladeを使用しますがAdmin用に分けても構いません。今回は簡易にするために、同じBladeですがguard変数があるかどうかでactionのURLを分けています。

```PHP:multi-auth/resources/views/auth/login.blade.php
<x-guest-layout>
    <x-jet-authentication-card>
        <x-slot name="logo">
            <x-jet-authentication-card-logo />
        </x-slot>

        <x-jet-validation-errors class="mb-4" />

        @if (session('status'))
        <div class="mb-4 font-medium text-sm text-green-600">
            {{ session('status') }}
        </div>
        @endif

        <!-- guard変数がセットされているかによってPOST先を変更 -->
        <form method="POST" action="{{ isset($guard) ? route('admin.login') : route('login') }}">
            @csrf

            <div>
                <x-jet-label for="email" value="{{ __('Email') }}" />
                <x-jet-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')"
                    required autofocus />
            </div>

            <div class="mt-4">
                <x-jet-label for="password" value="{{ __('Password') }}" />
                <x-jet-input id="password" class="block mt-1 w-full" type="password" name="password" required
                    autocomplete="current-password" />
            </div>

            <div class="block mt-4">
                <label for="remember_me" class="flex items-center">
                    <input id="remember_me" type="checkbox" class="form-checkbox" name="remember">
                    <span class="ml-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
                </label>
            </div>

            <div class="flex items-center justify-end mt-4">
                @if (Route::has('password.request'))
                <a class="underline text-sm text-gray-600 hover:text-gray-900" href="{{ route('password.request') }}">
                    {{ __('Forgot your password?') }}
                </a>
                @endif

                <x-jet-button class="ml-4">
                    {{ __('Login') }}
                </x-jet-button>
            </div>
        </form>
    </x-jet-authentication-card>
</x-guest-layout>
```

### AttemptToAuthenticateクラスをapp/Actions/Admin配下に作成
認証処理を実施するクラスです。

- {your-workspace-root}/multi-auth/vendor/laravel/fortify/src/Actions/AttemptToAuthenticate.phpからコピペして一部修正します。

```PHP:multi-auth/app/Actions/Admin/AttemptToAuthenticate.php
<?php

namespace App\Actions\Admin;

use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\LoginRateLimiter;

class AttemptToAuthenticate
{
    /**
     * The guard implementation.
     *
     * @var \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected $guard;

    /**
     * The login rate limiter instance.
     *
     * @var \Laravel\Fortify\LoginRateLimiter
     */
    protected $limiter;

    /**
     * Create a new controller instance.
     *
     * @param  \Illuminate\Contracts\Auth\StatefulGuard  $guard
     * @param  \Laravel\Fortify\LoginRateLimiter  $limiter
     * @return void
     */
    public function __construct(StatefulGuard $guard, LoginRateLimiter $limiter)
    {
        $this->guard = $guard;
        $this->limiter = $limiter;
    }

    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  callable  $next
     * @return mixed
     */
    public function handle($request, $next)
    {
        if (Fortify::$authenticateUsingCallback) {
            return $this->handleUsingCustomCallback($request, $next);
        }

        if ($this->guard->attempt(
            $request->only(Fortify::username(), 'password'),
            $request->filled('remember')
        )) {
            return $next($request);
        }

        $this->throwFailedAuthenticationException($request);
    }

    /**
     * Attempt to authenticate using a custom callback.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  callable  $next
     * @return mixed
     */
    protected function handleUsingCustomCallback($request, $next)
    {
        $user = call_user_func(Fortify::$authenticateUsingCallback, $request);

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

        $this->guard->login($user, $request->filled('remember'));

        return $next($request);
    }

    /**
     * Throw a failed authentication validation exception.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return void
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    protected function throwFailedAuthenticationException($request)
    {
        $this->limiter->increment($request);

        throw ValidationException::withMessages([
            Fortify::username() => [trans('auth.failed')],
        ]);
    }
}

```


### AdminLoginServiceProviderクラスの作成
- 先ほど作成したAttemptToAuthenticateクラスと後ほど作成するLoginControllerで使用するguardをDIするためにAdminLoginServiceProviderクラスを作成します。

```
$ php artisan make:provider AdminLoginServiceProvider
Provider created successfully.
```

```PHP:multi-auth/app/Providers/AdminLoginServiceProvider.php
<?php

namespace App\Providers;

use App\Http\Controllers\Auth\LoginController;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Support\Facades\Auth;
use App\Actions\Admin\AttemptToAuthenticate;
use Illuminate\Support\ServiceProvider;

class AdminLoginServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        $this->app
            ->when([LoginController::class, AttemptToAuthenticate::class])
            ->needs(StatefulGuard::class)
            ->give(function () {
                return Auth::guard('admin');
            });
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}
```

### app.phpを修正
作成したAdminLoginServiceProviderが反映されるようにapp.phpを修正します。

```PHP:multi-auth/config/app.php
~~省略~~
        /*
         * Application Service Providers...
         */
        App\Providers\AppServiceProvider::class,
        App\Providers\AuthServiceProvider::class,
        // App\Providers\BroadcastServiceProvider::class,
        App\Providers\EventServiceProvider::class,
        App\Providers\RouteServiceProvider::class,
        App\Providers\FortifyServiceProvider::class,
        App\Providers\JetstreamServiceProvider::class,
        App\Providers\AdminLoginServiceProvider::class, // この行を追加
```

### Admin認証用のLoginControllerを作成
- ログイン画面表示や認証、ログアウトなどのアクションを受けるLoginControllerをapp/Http/Controllers/Auth配下に作成します。
- {your-workspace-root}/multi-auth/vendor/laravel/fortify/src/Http/Controllers/AuthenticatedSessionController.phpを参考に、先ほどまでで作った各クラスを利用するように修正します。

```
$ php artisan make:controller Auth/LoginController
Controller created successfully.
```

```PHP:multi-auth/app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\Request;
use Illuminate\Routing\Pipeline;
use App\Actions\Admin\AttemptToAuthenticate;
use Laravel\Fortify\Actions\PrepareAuthenticatedSession;
use App\Responses\AdminLoginResponse;
use Laravel\Fortify\Contracts\LogoutResponse;
use Laravel\Fortify\Http\Requests\LoginRequest;

class LoginController extends Controller
{
    /**
     * The guard implementation.
     *
     * @var \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected $guard;

    /**
     * Create a new controller instance.
     *
     * @param  \Illuminate\Contracts\Auth\StatefulGuard
     * @return void
     */
    public function __construct(StatefulGuard $guard)
    {
        $this->guard = $guard;
    }

    /**
     * Show the login view.
     *
     * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
     */
    public function create()
    {
        return view('auth.login', ['guard' => 'admin']);
    }

    /**
     * Attempt to authenticate a new session.
     *
     * @param  \Laravel\Fortify\Http\Requests\LoginRequest  $request
     * @return mixed
     */
    public function store(LoginRequest $request)
    {
        return $this->loginPipeline($request)->then(function ($request) {
            return app(AdminLoginResponse::class);
        });
    }

    /**
     * Get the authentication pipeline instance.
     *
     * @param  \Laravel\Fortify\Http\Requests\LoginRequest  $request
     * @return \Illuminate\Pipeline\Pipeline
     */
    protected function loginPipeline(LoginRequest $request)
    {
        return (new Pipeline(app()))->send($request)->through(array_filter([
            AttemptToAuthenticate::class,
            PrepareAuthenticatedSession::class,
        ]));
    }

    /**
     * Destroy an authenticated session.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Laravel\Fortify\Contracts\LogoutResponse
     */
    public function destroy(Request $request): LogoutResponse
    {
        $this->guard->logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return app(LogoutResponse::class);
    }
}
```

### AdminDashboardControllerとadmin/dashboard.blade.phpの作成
- 管理者ログイン後のダッシュボード表示用にAdminDashboardControllerとadmin/dashboard.blade.phpを作成します。
- admin/dashboard.blade.phpはdashboard.blade.phpからコピペして一部編集します。

```
$ php artisan make:controller AdminDashboardController
Controller created successfully.
```

```PHP:multi-auth/app/Http/Controllers/AdminDashboardController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class AdminDashboardController extends Controller
{
    public function index()
    {
        return view('admin.dashboard');
    }
}
```

```html:/multi-auth/resources/views/admin/dashboard.blade.php
<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            <!-- わかりやすいようにここを修正-->
            管理者ダッシュボード
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
                <x-jet-welcome />
            </div>
        </div>
    </div>
</x-app-layout>
```

### 未ログイン時の挙動を追記
- User、Adminともに認証が必要なページ（今回はdashboard, admin/dashboard）にアクセスした場合、それぞれのログインページにリダイレクトさせるためにAuthenticate.phpに追記します。

```PHP:multi-auth/app/Http/Middleware/Authenticate.php
<?php

namespace App\Http\Middleware;

use Illuminate\Auth\Middleware\Authenticate as Middleware;

class Authenticate extends Middleware
{
    /**
     * Get the path the user should be redirected to when they are not authenticated.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string|null
     */
    protected function redirectTo($request)
    {
        if (!$request->expectsJson()) {
            if ($request->is('admin/*')) {
                return route('admin.login');
            }
            return route('login');
        }
    }
}
```

### web.phpの修正
- 管理者ログインとダッシュボード用のルーティングを追記します。

```PHP:multi-auth/routes/web.php
<?php

use App\Http\Controllers\AdminDashboardController;
use App\Http\Controllers\Auth\LoginController;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Route::prefix('admin')->group(function () {
    Route::get('login', [LoginController::class, 'create'])->name('admin.login');
    Route::post('login', [LoginController::class, 'store']);

    Route::middleware('auth:admin')->group(function () {
        Route::get('dashboard', [AdminDashboardController::class, 'index']);
    });
});

Route::middleware(['auth:web', 'verified'])->get('/dashboard', function () {
    return view('dashboard');
})->name('dashboard');
```

### auth.phpの修正
- 最後にauth.phpを修正し、Admin用の認証ガードであるadminを定義します。
- ~~ファイルを修正した後は`php artisan config:cache`で設定値を反映させましょう。~~ ※開発環境ではconfigをキャッシュするべきではありません。もし`php artisan config:cache`を行ったことがある場合は`php artisan config:clear`でキャッシュを消しちゃいましょう。
    - 参考：（自分の記事ですが）[【Laravel】開発環境では「php artisan config:cache」をするべきではない](https://qiita.com/nasteng/items/eb46d1b525944c586c67)

```PHP:multi-auth/config/auth.php
~~省略~~
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'admin' => [
            'driver' => 'session',
            'provider' => 'admins',
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
            'hash' => false,
        ],
    ],
~~省略~~
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Models\Admin::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],
~~省略~~
    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],

        'admins' => [
            'provider' => 'admins',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],
```

### ビルトインサーバーを起動して（`php artisan serve`）管理者ログインができるか確認

![スクリーンショット 2020-10-19 19.41.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115590/5deef22b-b34e-5948-c16f-aeea40c2ab1f.png)
![スクリーンショット 2020-10-19 19.42.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115590/8d4add37-5a2c-a2dc-6e3d-003db2e7f2fb.png)

無事に管理者ログインできました:tada:

# 最後に
Fortifyではかなり多くのクラスが登場し、なおかつDIがかなり使われているため最初はなかなかコードを追うのが大変でしたが、少しずつ読み解いていくことで新しい認証スキャフォールドについて理解を深めることができました。Laravelはいいぞ。

誤字・脱字、間違った実装などありましたらコメント等いただけますと幸いです🙏
それでは良いLaravelライフを🤚