Laravel 6でマルチ認証して一般ログインと管理ログインを分ける方法

　Laravel 6で一般ログインと管理ログインを分けるために、マルチ認証を実装する方法を紹介します。リセットパスワードまで含めてまとまっている情報が日本語英語ともに見つからなかったので、同じ問題を抱えている方の参考になれば。

# ゴール

- 一般ログインと管理ログインを分ける。
- Laravel標準のユーザー認証機能(Auth)を使う。
- コードはなるべく共用にする。
- 一般ログインURIは `/login` とする。
- 管理ログインURIは `/login/admin` とし、管理画面は `/admin` 以下とする。
- リセットパスワードができるようにする。

# 前提

- PHP 7.2の知識がある
- Laravel 6の知識がある、認証機能のあるアプリが作れる
- Laravel 6に必要な環境構築ができる
- Composerが使える状態にある
- [Laravel Installer](https://laravel.com/docs/6.x/installation)が使える状態にある

　Laravelで認証機能のあるアプリを作る知識と環境がある前提でスタートします。今回は一般ユーザー(user)と管理ユーザー(admin)の2種類の認証ができるようにします。3種類以上でも全く同じ方法で拡張できます。
　Windowsで作っているので、MacやLinuxで合わないところは読み替えてください。

# ベースとなるアプリを作る

まずはLaravel 6の `multi-auth` プロジェクトをサクッと作ります。

```bash
$ composer create-project laravel/laravel multi-auth "6.*"
$ cd multi-auth
```

データベースを作って、 `.env` に接続設定します。

```:.env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=qiita_multi_auth
DB_USERNAME=dbuser
DB_PASSWORD=password
```

動作することだけ確認しておきます。以下で簡易サーバー起動後、ブラウザで `http://localhost` を確認。

```bash
$ php artisan serve --port=80
```

# Migrationを作る

　Laravel標準で `users` テーブルのMigrationは準備されていますが、今回は管理ユーザーを別テーブルとしたいので `admins` テーブルを作ります。これにより、同じEmailアドレスを使って一般ユーザーと管理ユーザー両方のアカウントを正しく作成できます。

```bash
$ php artisan make:migration create_admins_table
```

　中身は `users` テーブルとほぼ同じです。必要に応じて追加情報を加えても良いでしょう。

```php:database/migrations/[timestamp]_create_admins_table.php
class CreateAdminsTable extends Migration
{
    [...]
    public function up()
    {
        Schema::create('admins', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->boolean('is_super')->default(false);
            $table->rememberToken();
            $table->timestamps();
        });
    }
    [...]
}
```

# テーブルを作る

必要なテーブルのMigration定義はOKなので、早速テーブルを作ります。

```bash
$ php artisan migrate
```

# Modelを作る

　`admins` の Model を作ります。

```bash
$ php artisan make:model Admin
```

　内容は、 `admins` テーブルに合わせて以下のように作ります。

```app/Admin.php
<?php

namespace App;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class Admin extends Authenticatable
{
    use Notifiable;

    protected $guard = 'admin';

    protected $fillable = [
        'name', 'email', 'password',
    ];

    protected $hidden = [
        'password', 'remember_token',
    ];
}
```

　`Admin` モデルは `Model` クラスではなく `Illuminate\Foundation\Auth\User` クラスを継承します。冒頭で名前を `Authenticatable` としています。また、 `use Notifiable;` でメールを送れるようにしておきます。このあたりの構造は `User` モデルが参考になります。
　`User` モデルとの違いとして、ユーザー認証をデフォルトのユーザーガードではなく `admin` という名前の別ガードにしたいので、 `$guard` を設定しています。

# Guardを作る

　先程設定した `admin` ガードを作ります。ガード追加は `config/auth.php` に定義するだけです。

```php:config/auth.php
    'guards' => [
        [...]
        'admin' => [
            'driver' => 'session',
            'provider' => 'admins',
        ],
    ],
```

　これで `admin` ガードに `admins` プロバイダーを設定しました。プロバイダーでは、どのモデルを認証に使うべきかなどが設定されます。 `admins` プロバイダーを同じファイルに追加します。今回は `users` プロバイダー同様に同じデータベース上の情報を使うので、同じ設定をモデル名だけ変更して使います。

```php:config/auth.php
    'providers' => [
        [...]
        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Admin::class,
        ],
    ],
```

# コントローラを修正する

　ログインとユーザー登録では、ユーザーが開けたページのURLからルーター経由でメンバメソッドが呼び出されるため、ガードごとにメソッドを分けることができ、1つのコントローラファイル内で複数のガードの動作を別メソッドとして書くことができます。まずはこの２つを実装します。

## ログインコントローラの修正

```php:app/Http/Controllers/Auth/LoginController.php
    public function __construct()
    {
        $this->middleware('guest')->except('logout');
        $this->middleware('guest:admin')->except('logout');
    }
```

　ログインコントローラに、 `admin` ガードのゲストとしてのアクセスを許可するミドルウェアを登録します。これにより、デフォルトのユーザーログインまたは管理ログインをしている場合に、ゲスト扱いされずこのコントローラへのアクセスを拒否できます。これによって、一般ログインと管理ログインを同時にしている状態を防げます。

　次に、管理ログイン用の動作を追加します。

```php:app/Http/Controllers/Auth/LoginController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
[...]
    public function showAdminLoginForm()
    {
        return view('auth.login', ['authgroup' => 'admin']);
    }

    public function adminLogin(Request $request)
    {
        $this->validate($request, [
            'email'   => 'required|email',
            'password' => 'required|min:6'
        ]);

        if (Auth::guard('admin')->attempt(['email' => $request->email, 'password' => $request->password], $request->get('remember'))) {

            return redirect()->intended('/admin');
        }
        return back()->withInput($request->only('email', 'remember'));
    }
```

　この2つのメソッドは、 `vendor\laravel\framework\src\Illuminate\Foundation\Auth\AuthenticatesUsers.php` の `showLoginForm()` と `login()` メソッドを書き換えたものです。 `adminLogin()` の方はログイン回数とロックアウトの管理を省略しています。本来なら、元通りのロジックで関連するメソッドも含めてきちんと作るほうが良いかもしれません。

 フォームの表示をするときに、追加パラメータとして `authgroup` に `admin` を設定して置きます。これにより、管理ユーザー用にフォーム表示する場合にはブレード側で表示内容を変更できるようにします。これにより、ログインフォームの送信先メソッドを、一般ユーザーで使う `login()` ではなく `adminLogin()` にすることを狙っています。

## ユーザー登録コントローラの修正

```php:app\Http\Controllers\Auth\RegisterController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
[...]
    public function __construct()
    {
        $this->middleware('guest');
        $this->middleware('guest:admin');
    }
```

　ユーザー登録コントローラも `LoginController` 同様にガードコントロールのミドルウェアを登録しておきます。

```php:app\Http\Controllers\Auth\RegisterController.php
use App\Admin;
[...]
    protected function adminValidator(array $data)
    {
        return Validator::make($data, [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:admins'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ]);
    }

    public function showAdminRegisterForm()
    {
        return view('auth.register', ['authgroup' => 'admin']);
    }

    protected function createAdmin(Request $request)
    {
        $this->adminValidator($request->all())->validate();
        $admin = Admin::create([
            'name' => $request['name'],
            'email' => $request['email'],
            'password' => Hash::make($request['password']),
        ]);
        return redirect()->intended('login/admin');
    }
```

　ログイン同様、管理ユーザー用の動作を定義しておきます。`adminValidator()` では、メールアドレスの重複チェックを`admins`テーブルで実施するように指定しています。

# 認証機能のための画面を導入

　Laravel 6ではデフォルトの状態でLaravelの認証関連機能が使える状態になっていますが、認証関連のデフォルト画面などは使える状態になっていません。標準のUIパッケージの中に認証関連のView(=Blade)が入っているので、まずUIパッケージを入れます。最新のUIパッケージバージョンはLaravel 7用なので、Laravel 6対応のバージョン1を指定して入れます。

```bash
$ composer require laravel/ui="1.*"
```

　次に認証関連のViewをUIのコマンドで作成します。デフォルトで実行すると、ついでに最低限必要なルートやログイン後のホームページ(`HomeController@index`)を作ってくれます。

```bash
$ php artisan ui:auth
```

　これで `resources/views/auth` に関連画面のBladeができたので、一般ログインだけでなく管理ログインにも対応させていきます。

```php:resources\views\auth\login.blade.php
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Login') }}</div>

                <div class="card-body">
                    @isset($authgroup)
                    <form method="POST" action="{{ url("login/$authgroup") }}">
                    @else
                    <form method="POST" action="{{ route('login') }}">
                    @endisset
                        @csrf
```

　`authgroup` パラメータがある場合、ログイン画面のタイトル表示を変更し、またログインに使う action URL を変更しています。これにより、一般ログインは `/login` 、管理ログインは `/login/admin` にPOSTで要求されることになります。
　登録画面も同様に変更します。

```php:resources\views\auth\register.blade.php
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Register') }}</div>

                <div class="card-body">
                    @isset($authgroup)
                    <form method="POST" action="{{ url("register/$authgroup") }}">
                    @else
                    <form method="POST" action="{{ route('register') }}">
                    @endisset
                        @csrf
```

　これでコンテンツエリアは `authgroup` に対応しましたが、ヘッダーエリアも対応させるためにレイアウトも編集します。

```php:resources\views\layouts\app.blade.php
                    <!-- Right Side Of Navbar -->
                    <ul class="navbar-nav ml-auto">
                        <!-- Authentication Links -->
                        @if(!Auth::check() && (!isset($authgroup) || !Auth::guard($authgroup)->check()))
                            <li class="nav-item">
                                @isset($authgroup)
                                <a class="nav-link" href="{{ url("login/$authgroup") }}">{{ __('Login') }}</a>
                                @else
                                <a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a>
                                @endisset
                            </li>
                            @isset($authgroup)
                            @if (Route::has("$authgroup-register"))
                                <li class="nav-item">
                                    <a class="nav-link" href="{{ route("$authgroup-register") }}">{{ __('Register') }}</a>
                                </li>
                            @endif
                            @else
                            @if (Route::has('register'))
                                <li class="nav-item">
                                    <a class="nav-link" href="{{ route('register') }}">{{ __('Register') }}</a>
                                </li>
                            @endif
                            @endisset
                        @else
                            <li class="nav-item dropdown">
                                <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
                                    @isset($authgroup)
                                    {{ Auth::guard($authgroup)->user()->name }} <span class="caret"></span>
                                    @else
                                    {{ Auth::user()->name }} <span class="caret"></span>
                                    @endisset
                                </a>
```

# 管理ユーザー用のログイン後画面作成

　デフォルトの `home.blade.php` をコピーして、 `admin.blade.php` を作ります。

```bash
$ cp resources/views/home.blade.php resources/views/admin.blade.php
```

　ちょっと書き換えて、管理者へのメッセージを入れましょう。

```php:resources/views/admin.blade.php
@extends('layouts.app', ['authgroup'=>'admin'])

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">管理者 Dashboard</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

                    You are logged in as 管理者!
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
```

　ここで`@extends`するときに、この画面が`admin`ガードであることをパラメータで指定していますが、これはコントローラ無しでルートから直接このブレードを表示しようとしているため、ここでパラメータ指定しています。

# ルートを作成

　ルートファイルにはすでに `Auth` に必要なルート設定はされていますので、その後ろに管理ユーザー用のルートを追加します。

```php:routes/web.php
Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

// ここから追加
Route::get('/login/admin', 'Auth\LoginController@showAdminLoginForm');
Route::get('/register/admin', 'Auth\RegisterController@showAdminRegisterForm');

Route::post('/login/admin', 'Auth\LoginController@adminLogin');
Route::post('/register/admin', 'Auth\RegisterController@createAdmin')->name('admin-register');

Route::view('/admin', 'admin')->middleware('auth:admin')->name('admin-home');
```

# ログイン後のリダイレクト先設定

　ログイン後、Laravelのデフォルトでは`/home`にリダイレクトされますが、管理ユーザーは`/admin`にリダイレクトさせるようにします。これには認証後リダイレクトのミドルウェアを編集します。

```php:app\Http\Middleware\RedirectIfAuthenticated.php
    public function handle($request, Closure $next, $guard = null)
    {

        if ($guard == "admin" && Auth::guard($guard)->check()) {
            return redirect(route('admin-home'));
        }
        if (Auth::guard($guard)->check()) {
            return redirect(RouteServiceProvider::HOME);
        }

        return $next($request);
    }
```

　`RedirectIfAuthenticated`ミドルウェアは、`guard`パラメータを受け取るので、これを見て処理を判断しています。

# 認証例外発生時のリダイレクト先設定

　ログインしていない状態で保護されたページを見ようとした場合、今のままではとにかく`/login`にリダイレクトされます。これだと、管理画面にアクセスしようとしたときに一般ログイン画面に飛ばされてしまい、不便です。管理画面用のルートからは`/login/admin`にリダイレクトするようにします。

```php:app\Exceptions\Handler.php
use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
[...]
class Handler extends ExceptionHandler
{
    [...] 
    protected function unauthenticated($request, AuthenticationException $exception)
    {
        if ($request->expectsJson()) {
            return response()->json(['message' => $exception->getMessage()], 401);
        }
        if ($request->is('admin') || $request->is('admin/*')) {
            return redirect()->guest('/login/admin');
        }

        return redirect()->guest($exception->redirectTo() ?? route('login'));
    }
```

# 動作確認

　ここまでで、一般ユーザーの登録、ログイン、ホーム画面、ログアウト、管理ユーザーの登録、ログイン、ホーム画面、ログアウトができるようになりました。一度簡易サーバーを起動して動作を確認してみてください。

```bash
$ php artisan serve --port=80
```

　残るはリセットパスワードです。

# 追加準備

　`.env` でメールが送信できるように設定しておきます。パスワードリセット用のメールがこの設定を使って送信されます。

```:.env
MAIL_DRIVER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=--------@gmail.com
MAIL_PASSWORD=---------
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=--------@gmail.com
MAIL_FROM_NAME="ローカルテスト"
```

# パスワードリセット設定を作る

　`config/auth.php` に管理ユーザー用のパスワードリセット設定を作ります。

```php:config/auth.php
    'passwords' => [
        [...]
        'admins' => [
            'provider' => 'admins',
            'table' => 'password_resets',
            'expire' => 15,
        ],

```

# パスワードリセット関連のコントローラを追加する

　ログインやユーザー登録と違って、パスワードリセットではコントローラのメンバ変数にガードの指定が必要など、1つのコントローラファイル内で処理が難しいので、アプローチを変えてコントローラ自体を別に作ることにします。

## リセットリクエストコントローラの作成

　パスワードリセットリクエスト画面を表示するためのコントローラである`ResetPasswordController`をコピーします。

```bash
$ cp app\Http\Controllers\Auth\ResetPasswordController.php app\Http\Controllers\Auth\AdminResetPasswordController.php
```

```php:app\Http\Controllers\Auth\AdminResetPasswordController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
[...]
class AdminResetPasswordController extends Controller
{
    [...]
    protected $redirectTo = '/admin';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest:admin');
    }

    public function showResetForm(Request $request, $token = null)
    {
        return view('auth.passwords.reset')->with(
            ['token' => $token, 'email' => $request->email, 'authgroup' => 'admin']
        );
    }

    protected function guard()
    {
        return Auth::guard('admin');
    }

    protected function broker()
    {
        return Password::broker('admins');
    }
```

　`guard()` をオーバーライドして`admin`ガードを指定し、`broker()`をオーバーライドして`config/auth.php`に設定したパスワードリセット設定をブローカーに指定します。あとはログインコントローラのときと同様です。メソッド名ではなく、クラス名そのものが変わっていることに注意してください。

## リセット実施コントローラの作成

　同様に、パスワードリセット実施画面を表示するためのコントローラである`ForgotPasswordController`をコピーし、`broker()`をオーバーライドします。

```bash
$ cp app\Http\Controllers\Auth\ForgotPasswordController.php app\Http\Controllers\Auth\AdminForgotPasswordController.php
```

```php:app\Http\Controllers\Auth\AdminForgotPasswordController.php
use Illuminate\Support\Facades\Password;
[...]
class AdminForgotPasswordController extends Controller
{
    [...]
    use SendsPasswordResetEmails;

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest:admin');
    }

    public function showLinkRequestForm()
    {
        return view('auth.passwords.email')->with(
            ['authgroup' => 'admin']
        );
    }

    protected function broker()
    {
        return Password::broker('admins');
    }
}
```

# パスワードリセットリクエスト画面修正

　パスワードリセットリクエスト画面でも、画面上の表示とフォームの`action`をガードによって変更するように修正します。

```php:resources\views\auth\passwords\email.blade.php
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Reset Password') }}</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

                    @isset($authgroup)
                    <form method="POST" action="{{ route("$authgroup.password.email") }}">
                    @else
                    <form method="POST" action="{{ route('password.email') }}">
                    @endisset

                        @csrf
```

# パスワードリセット実施画面修正

　これも同様に変更します。

```php:resources\views\auth\passwords\reset.blade.php
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Reset Password') }}</div>

                <div class="card-body">
                    @isset($authgroup)
                    <form method="POST" action="{{ route($authgroup.'.password.update') }}">
                    @else
                    <form method="POST" action="{{ route('password.update') }}">
                    @endisset

                        @csrf
```

# ログイン画面修正

　「パスワードを忘れた」のリンク先を制御するため、ログイン画面を修正します。

```php:resources\views\auth\login.blade.php
                        <div class="form-group row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Login') }}
                                </button>

                                @if (Route::has(isset($authgroup) ? $authgroup.'.password.request' : 'password.request'))
                                    <a class="btn btn-link" href="{{ route(isset($authgroup) ? $authgroup.'.password.request' : 'password.request') }}">
                                        {{ __('Forgot Your Password?') }}
                                    </a>
                                @endif
                            </div>
                        </div>
```

# リセットパスワードメールを作成

　リセットパスワードのメールには、パスワードリセットのためのリンクボタンが含まれます。このリンクを変更するには、このリンクを含む管理ユーザー用のパスワードリセット通知を新しく作って、管理ユーザーのリセットの場合にはそれを使うように設定する必要があります。

## 管理ユーザー用パスワードリセット通知の作成

```bash
$ php artisan make:notification AdminResetPassword
```

実際の中身は、`Illuminate\Auth\Notifications\ResetPassword`を継承して以下のように`toMail()`を親からコピーしてきて少し改造します。

```php:app\Notifications\AdminResetPassword.php
<?php

namespace App\Notifications;

use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Lang;

class AdminResetPassword extends Resetpassword
{
    use Queueable;

    public function __construct($token)
    {
        $this->token = $token;
    }

    public function toMail($notifiable)
    {
        if (static::$toMailCallback) {
            return call_user_func(static::$toMailCallback, $notifiable, $this->token);
        }

        return (new MailMessage)
            ->subject(Lang::get('Reset Password Notification'))
            ->line(Lang::get('You are receiving this email because we received a password reset request for your account.'))
            ->action(Lang::get('Reset Password'), url(route('admin.password.reset', ['token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset()], false)))
            ->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]))
            ->line(Lang::get('If you did not request a password reset, no further action is required.'));
    }
}
```

## 管理ユーザー用パスワードリセット通知をモデルに設定

　パスワードリセットに使われる通知は、`Authenticatable`なユーザーモデルに設定されています。デフォルトでは`Illuminate\Auth\Notifications\ResetPassword`ですが、これを`app/Admin`モデルでは上で作った通知に変更します。

```php:app\Admin.php
use App\Notifications\AdminResetPassword;
[...]
class Admin extends Authenticatable
{
    [...]
    // Override default reset password
    public function sendPasswordResetNotification($token)
    {
        $this->notify(new AdminResetPassword($token));
    }
```

# パスワードリセット用ルートを作成

　ルートファイルに追記します。

```php:routes/web.php
Route::get('password/admin/reset', 'Auth\AdminForgotPasswordController@showLinkRequestForm')->name('admin.password.request');
Route::post('password/admin/email', 'Auth\AdminForgotPasswordController@sendResetLinkEmail')->name('admin.password.email');
Route::get('password/admin/reset/{token}', 'Auth\AdminResetPasswordController@showResetForm')->name('admin.password.reset');
Route::post('password/admin/reset', 'Auth\AdminResetPasswordController@reset')->name('admin.password.update');
```

以上です！お疲れ様でした。

# 参考サイト

https://pusher.com/tutorials/multiple-authentication-guards-laravel