Laravel8+laravel/breezeでマルチログイン

この記事は[アクシス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/mediaxis) 1日目です。
株式会社アクシスの渋谷オフィスdev-campの成果を基に作成しています。

## はじめに

Laravel8では、今までのlaravel/uiに代わる公式の認証パッケージとして[Jetstream](https://jetstream.laravel.com/1.x/introduction.html)が導入されました。
laravel/uiが使えなくなったわけじゃないですが、Jetstreamの方が公式の推しっぽい。[認証のドキュメントで紹介されてるのもJetstreamですし](https://readouble.com/laravel/8.x/ja/authentication.html)。

とはいえこのJetstream、とりあえずで使うにはあまりにも全部盛りが過ぎるためか、Laravelの中の人が新たに認証パッケージを開発していて、それが[laravel/breeze](https://github.com/laravel/breeze)です。

ただlaravel/breezeはデフォルトではUserのログインしかできないので、AdminとUserの2種類でログインできるようにしてみよう、というのがこの記事の主旨です。
※ ひとまず2種類のユーザーでログインできるところまでで、メール認証やパスワード再設定とかまでは手が回りませんでした。

## laravel/breezeについてもう少し補足

- vueやreact不使用。controllerとbladeだけ使って、普通のwebアプリとして動きます。私のようなフロントエンドよくわからんおじさんでも安心。
- ただし[tailwind css](https://tailwindcss.com/)を使うのでnodeは必要です。laravel-mixが介護してくれるので頑張りましょう。
- controllerやbladeは全部プロジェクト内の`app`や`resources`にコピーされるので、カスタマイズ自由。`vendor`の中にコードがあって手出しできない…みたいなことにはなりません。
- Jetstream(気流), tailwind(追い風), breeze(そよ風)となんか風関連のネーミングでまとめてますね。Laravel界隈はそういう機運なのか。

## 構成

環境構築は大体docker-composeを使いますが、nodeだけはasdfを使ってホストPCにローカルインストール。

- macOS Catalina 10.15.7
- node v14.14.0
- docker desktop 2.5.0.1
    - php:7.4-fpm-alpine
    - nginx:1.18.0
    - composer:2
    - redis:5.0.7 セッションに使うだけです
    - mailhog:1.0.1 smtpサーバとして使うつもりでしたが今回は不要というか、そこまでできなかった…
- Laravel 8.16.1
- laravel/breeze 0.1.0

## セットアップ

### Laravel本体のインストール

```bash

$ composer create-project --prefer-dist laravel/laravel [何か適当にプロジェクト名]
```

### laravel/breezeインストール

[リポジトリのREADME](https://github.com/laravel/breeze/blob/master/README.md)に従って

```bash
$ composer composer require laravel/breeze --dev
$ php artisan breeze:install
```

ここまででプロジェクト内にディレクトリが作られ、controllerやresources, routeなどのファイルが自動生成されます。こんな感じの構成に。
![スクリーンショット 2020-11-30 2.15.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/854172/9343bdcf-131c-56f9-cb8f-8f95483e2b47.png)
![スクリーンショット 2020-11-30 2.15.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/854172/b7ef08b5-ac28-778d-9654-ef93dad30a2c.png)

`breeze:install`完了後は以下のメッセージが出るので

```bash
Breeze scaffolding installed successfully.
Please execute the "npm install && npm run dev" command to build your assets.
```

言われたとおりにnpm実行。
`laravel/breeze`に`laravel-mix`が同梱されてるので、コマンド叩くだけで動きます。

```bash
$ npm install && npm run dev
```

完了したら`http://localhost:8231`を開いてみましょう。
![スクリーンショット 2020-11-30 2.33.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/854172/9457e47f-ee32-c8d1-599c-8c9b2e8f97a3.png)

ページ右上に`Login`, `register`が出てきましたね。
HelloWorld的なやつはここで終わりです。
以降はカスタマイズしてマルチログインを実装していきます。

## Adminユーザー作成

管理画面にログインするユーザーを想定したAdminモデルを作成します。`-m`でマイグレーションも一緒に。

```bash
$ php artisan make:model AdminModel -m
```

モデルクラスに認証機能を持たせるため、継承元を変更します。

```php
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Notifiable;

class Admin extends Authenticatable # ModelからAuthenticatableに変更
{
    use HasFactory, Notifiable;
    protected $guard = 'admins';
    protected $fillable = [
        'name',
        'email',
        'password',
    ];
    protected $hidden = [
        'password',
        'remember_token',
    ];
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];
}
```

続いてマイグレーション作成。`users`テーブルのマイグレーションファイルを丸パクリです。

```php
    public function up()
    {
        Schema::create('admins', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
            $table->softDeletes();
        });
    }
```

マイグレーションを実行します。

```bash
$ app php artisan migrate
```

これでAdminユーザーを扱うテーブルとモデルクラスが出来上がりました。
続いてルート設定です。

## ルート設定

### `routes/`に設定ファイル作成

前述の`breeze:install`でルート設定ファイルも設置されています。
`routes/auth.php`を見てみましょう。結構な行数があるのでコードの貼り付けはしません。カスタマイズ前のコードを見たい方は[リポジトリのコードをどうぞ](https://github.com/laravel/breeze/blob/master/stubs/routes/auth.php)。

やはりルート設定もUserだけがログインする想定ですので、Adminのルートを追加します。
`routes/web.php`, `routes/auth.php`は削除し、`routes/user.php`, `routes/admin.php`を作成しています。
実際のコードは↓をご覧ください。web.phpとauth.phpをマージしたような内容です。
なおprefixはここでは設定しません。次項で説明します。

- [routes/user.php](https://github.com/nomura-axis/breeze-test/blob/main/routes/user.php)
- [routes/admin.php](https://github.com/nomura-axis/breeze-test/blob/main/routes/admin.php)

### ルートプロバイダ

`app/Providers/RouteServiceProvider.php`を編集します。
laravel/breezeが設定している`HOME`がユーザー向けのリダイレクト先なので、AdminユーザーのホームURLとなる定数`ADMIN_HOME`を設定します。

```php
    public const HOME = '/dashboard';
    public const ADMIN_HOME = '/admin/dashboard'; // 追記
```

続いて`boot()`メソッドにUserとAdminのルートを設定します。
`example.com/`でUser、`example.com/admin`はAdminユーザーがアクセスする領域を想定し、`prefix`を設定します。
また`as()`で名前をつけることでルート名にもprefixがつき、`route()`の引数を見るだけで何のルートを出力するのか分かりやすくします。

```php
    public function boot()
    {
        $this->configureRateLimiting();

        $this->routes(function () {
            Route::prefix('api')
                ->middleware('api')
                ->namespace($this->namespace)
                ->group(base_path('routes/api.php'));

            Route::prefix('/')
                ->as('user.')
                ->middleware('web')
                ->namespace($this->namespace)
                ->group(base_path('routes/user.php'));

            Route::prefix('admin')
                ->as('admin.')
                ->middleware('web')
                ->namespace($this->namespace)
                ->group(base_path('routes/admin.php'));
        });
    }
```

これでルート設定は完了しました。
次はGuard設定です。

## Guard設定

Laravel標準の認証用機能であるGuardを使用します。
Guardについての説明はそれだけで1本の記事になってしまうので、参考リンクの紹介にとどめます。
ただ、今回のカスタマイズで「なぜそうするのか」の理解につながるので、一読をおすすめします。

- [Laravel 8.x 認証](https://readouble.com/laravel/8.x/ja/authentication.html) > 自前のユーザー認証
- [Laravel の Guard（認証） って実際何をやっているのじゃ？
](https://qiita.com/tomoeine/items/40a966bf3801633cf90f)

では`config/auth.php`を編集しましょう。変更箇所だけ抜粋します。

```php
    'defaults' => [
        'guard' => 'users', // ここ変更
        'passwords' => 'users',
    ],
```

```php
    'guards' => [
        // 追記ここから
        'users' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'admins' => [
            'driver' => 'session',
            'provider' => 'admins',
        ],
        // ここまで

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
            'hash' => false,
        ],
    ],
```

```php
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        // adminsを追加
        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Models\Admin::class,
        ],
    ],
```

```php
    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
        // adminsを追加
        'admins' => [
            'provider' => 'admins',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],
```

Guard設定追加完了。まだまだ続きます。

## Middleware設定

### Authenticate

アクセスしたユーザーが未認証の場合のリダイレクト処理を書きます。全体的に変えてます。

```php
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Support\Facades\Route;

class Authenticate extends Middleware
{
    protected string $user_route  = 'user.login';
    protected string $admin_route = 'admin.login';

    protected function redirectTo($request)
    {
        if (!$request->expectsJson()) {
            if (Route::is('user.*')) {
                return route($this->user_route);
            } elseif (Route::is('admin.*')) {
                return route($this->admin_route);
            }
        }
    }
}
```

### RedirectIfAuthenticated

こちらは「ログイン済みのユーザーがアクセスしてきたらリダイレクトする」処理を書きます。
例えばログイン済みユーザーがログインフォームにアクセスした時とかですね。
リダイレクト先のURLは先述のルートプロバイダで定義したものです。

```php
class RedirectIfAuthenticated
{
    private const GUARD_USER = 'users';
    private const GUARD_ADMIN = 'admins';

    public function handle(Request $request, Closure $next, ...$guards)
    {
        if (Auth::guard(self::GUARD_USER)->check() && $request->routeIs('user.*')) {
            return redirect(RouteServiceProvider::HOME);
        }
        if (Auth::guard(self::GUARD_ADMIN)->check() && $request->routeIs('admin.*')) {
            return redirect(RouteServiceProvider::ADMIN_HOME);
        }

        return $next($request);
    }
}

```

ミドルウェアはこの2つのみで、次はリクエストクラスです。
だいぶ長いですね、ダレてきそうです。もうちょっとお付き合いください。

## リクエストクラス

ログインフォームで入力した値を処理する`app/Http/Requests/Auth/LoginRequest.php`を編集します。
Admin用とUser用のどちらのフォームからログインしてきたかを判別して、`Auth::guard()`に渡しています。

```php
    public function authenticate()
    {
        $this->ensureIsNotRateLimited();

        // 追加ここから
        if ($this->routeIs('admin.*')) {
            $guard = 'admins';
        } else {
            $guard = 'users';
        }

        if (! Auth::guard($guard)->attempt($this->only('email', 'password'), $this->filled('remember'))) {
        // 追加ここまで
            RateLimiter::hit($this->throttleKey());

            throw ValidationException::withMessages([
                'email' => __('auth.failed'),
            ]);
        }

        RateLimiter::clear($this->throttleKey());
    }
```

もうちょっとだけ続くんじゃ。

## Admin用のcontrollerやbladeを作成する

### ファイル複製

UserとAdminとでディレクトリを分けてcontrollerを作成します。bladeも同様。
breezeがインストールしたファイルをがさっとコピーして、User用とAdmin用とに分けます。

- controller
    - `app/Http/Controllers/Auth` → `app/Http/Controllers/User/Auth`にディレクトリごと移動
    - `app/Http/Controllers/User/Auth` をコピーして `app/Http/Controllers/Admin/Auth`にディレクトリごとリネーム
- view
    - `resources/views/auth` → `resources/views/user/auth`にディレクトリごと移動
    - `resources/views/user/auth` をコピーして `resources/views/admin/auth`にディレクトリごとリネーム

### コード修正

元々は`route()`の引数が`login`とか`register`みたいになってますが、ルート設定の作業を経て`admin.login`や`user.dashboard`といったルート名になってますので、それに合わせて修正していきます。
ルート一覧の確認はおなじみの`php artisan route:list`で。` | grep "admin."`とかやれば、adminだけのルート一覧表示とかできます。

ログイン/ログアウト処理も少し手を入れます。
たとえばAdminのログインは`App\Http\Controllers\User\Auth\AuthenticatedSessionController::store()`ですが、リダイレクト先が`RouteServiceProvider::HOME`になってるので、これを`RouteServiceProvider::ADMIN_HOME`に直したり。
一方でログアウト処理は同クラスの`destroy()`ですが、ここで呼び出してる`Auth::logout()`も、まずguardを指定してからログアウトします。
指定しないと「AdminでログアウトしたのにUserの方でログアウトした」みたいなことになります。
具体的には以下のようなコードに。

```php
    public function destroy(Request $request)
    {
        // guardを指定してログアウト
        Auth::guard('admins')->logout();

        // invalidate()でセッションデータ全体が破棄されてしまうのでコメントアウト
        //$request->session()->invalidate();

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

        return redirect('/');
    }
```

パスワードリセットとかメールアドレス確認の処理には今回手を入れてないんですが、guardの指定はたぶん`Auth::***()`的なコード全部が対象になるんじゃないでしょうか。まず`Auth::guard('admins')`してから`->validate()`とか`->login()`とか呼び出す感じで。

で、出来上がったものが以下になります。これも結構な量になるのでgithubをご覧ください。
viewはcomponentsやlayoutなどの共通パーツはそのままで、各ページのメインコンテンツのbladeを分けた感じ。

- [controller](https://github.com/nomura-axis/breeze-test/tree/main/app/Http/Controllers)
- [view](https://github.com/nomura-axis/breeze-test/tree/main/resources/views)

作業は以上です。おつかれさまでした。
このカスタマイズで、ひとまずログイン/ログアウト部分はマルチユーザーに対応できました。

## さいごに

laravel/breezeは本当に最低限のスキャフォールドしかせず、あとはお好きなようにって感じですね。
guardとかの理解さえすれば、柔軟に対応できていいんじゃないでしょうか。そもそもLaravel標準の認証はguardだし。
私も今回の記事を書くにあたって、今まで何となく使ってた`Auth::user()`が何してるのかとか理解できてよかったです。
あと、controllerとbladeで全部解決してるので、vueやreactがよく分からない古のwebアプリおじさんにも安心。

ところで私はPHP開発にPHPStormを使ってますが、今回[laravel idea](https://plugins.jetbrains.com/plugin/13441-laravel-idea)をお試し導入しました。ルート名やviewファイルの入力補完とかしてくれて、地味に便利。30日トライアルだけど買っちゃうか…