Laravel 11 web画面を使用したマルチ認証導入手順

## やること

- ユーザーのログイン認証を作成

- 管理のログイン認証を作成

- ログイン状態をsessionsテーブルでそれぞれ独立して管理できるように対応

## 環境

- OS
    - Debian GNU/Linux 12 (bookworm)
- PHP
    - 8.3.10
- docker

## 参考

[【Laravel】マルチログイン機能を作ってみる（Laravel11, 10）](https://zenn.dev/seiya0/articles/tech-laravel-multi-login)

[Laravel マルチログインでセッションをDB管理する方法](https://qiita.com/ma_3636/items/fddcf7e3206c93d1dced)

## プロジェクト作成

```bash
composer create-project laravel/laravel laravel_web_multi_auth
```

### 起動


```bash
php artisan serve
```

dockerを使用している場合

```bash
php artisan serve --host=0.0.0.0
```

## ユーザーの認証

ユーザーの認証を作成し、動作確認後にアドミンの認証を作成します。

### seeder

- database/seeders/DatabaseSeeder.php

```diff_php
+use DateTime;
+use Illuminate\Support\Facades\Hash;

/***　略 ***/

public function run(): void
    {
-        User::factory()->create([
-            'name' => 'Test User',
-            'email' => 'test@example.com',
-        ]);
+        if (app()->isLocal()) {
+            // 開発環境のみ
+            User::factory()
+                ->count(10)
+                ->sequence(function ($sequence) {
+                    return [
+                        'name' => sprintf('user_%02d', $sequence->index + 1),
+                        'email' => sprintf('user_%02d@example.com', $sequence->index + 1),
+                        'password' => Hash::make(sprintf('user_%02d_password', $sequence->index + 1)),
+                        'created_at' => new DateTime(),
+                        'updated_at' => new DateTime(),
+                    ];
+                })
+            ->create();
+        }
    }
```

### seeder実行

```bash
php artisan db:seed
```

### コントローラー作成

```bash
php artisan make:controller UserLoginController
```

- app/Http/Controllers/UserLoginController.php

```php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\UserLoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class UserLoginController extends Controller
{
    /**
     * ログイン画面
     */
    public function create(): View {
        return view('user.login');
    }

    /**
     * ログイン
     */
    public function store(UserLoginRequest $request): RedirectResponse {
        $request->authenticate();
        $request->session()->regenerate();
        return redirect()->intended(route('user.top'));
    }

    /**
     * ログアウト
     */
    public function destroy(Request $request): RedirectResponse {
        $request->session()->invalidate();
        $request->session()->regenerateToken();
        return to_route('user.login');
    }
}

```

### リクエスト作成

```bash
php artisan make:request UserLoginRequest
```

- app/Http/Requests/UserLoginRequest.php

```diff_php
    public function authorize(): bool
    {
-        return false;
+        return true;
    }

    /***　略 ***/

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
+            'email' => ['required', 'email'],
+            'password' => 'required',
        ];
    }

+    public function attributes(): array
+    {
+        return [
+            'email' => 'E-mail',
+            'password' => 'Password',
+        ];
+    }
+
+    public function messages(): array
+    {
+        return [
+            'email.required' => ':attributeを入力してください',
+            'email.email' => ':attributeが正しくありません',
+            'password.required' => ':attributeを入力してください',
+        ];
+    }
+
+    public function authenticate(): void
+    {
+        // attemptでログインを試みる
+        if (!Auth::attempt($this->only(['email', 'password']))) {
+            throw ValidationException::withMessages(['failed' => __('auth.failed')]);
+        }
+    }
}
```

### ミドルウェア
- bootstrap/app.php

```diff_php
+use Illuminate\Http\Request;

/***　略 ***/

    ->withMiddleware(function (Middleware $middleware) {
+        $middleware->redirectGuestsTo(function(Request $request) {
+            if (request()->routeIs('user.*')) {
+                return $request->expectsJson() ? null : route('user.login');
+            }
+            return $request->expectsJson() ? null : route('auth');
+        });
    })
```

### ルーティング

- routes/web.php

```diff
+use App\Http\Controllers\UserLoginController;

/***　略 ***/

+// ユーザーログイン画面
+Route::get('/user-login', [UserLoginController::class, 'create'])->name('user.login');
+// ユーザーログイン
+Route::post('/user-login', [UserLoginController::class, 'store'])->name('user.login.store');
+// ユーザーログアウト
+Route::delete('/user-login', [UserLoginController::class, 'destroy'])->name('user.login.destroy');
+
+// ユーザーログイン後のみアクセス可
+Route::middleware('auth:web')->group(function () {
+    Route::get('/user-top', function () {
+        return view('user.top');
+    })->name('user.top');
+});
```

### ビュー

#### ログインView

```bash
php artisan make:view user/login
```

- resources/views/user/login.blade.php

```html
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>ユーザー</title>
        <style>
            html,
            body {
                height: 100%;
            }

            body {
                margin: 0;
                display: flex;
                justify-content: center;
                align-items: center;
            }

            li {
                list-style: none;
            }

            form {
                text-align: right;
            }

            .alert {
                width: 300px;
                padding: 8px;
            }

            .alert ul {
                padding: 0;
            }

            .alert ul li {
                color: red;
                text-align: left;
                overflow-wrap: break-word;
            }
        </style>
    </head>

    <body>
        <main>
            <h2>ユーザーログイン</h2>
            <form method="POST" action="{{ route('user.login.store') }}">
                @csrf
                <div>
                    <label for="email">E-main: </label>
                    <input type="text" id="email" name="email" required>
                </div>
                <div>
                    <label for="password">Password: </label>
                    <input type="password" id="password" name="password" required>
                </div>
                <div class="alert">
                    <ul>
                        @foreach ($errors->all() as $error)
                            <li>{{ $error }}</li>
                        @endforeach
                    </ul>
                </div>
                <button type="submit">ログイン</button>
            </form>
        </main>
    </body>
</html>

```

#### トップView

```bash
php artisan make:view user/top
```

- resources/views/user/top.blade.php

```html
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>ユーザー</title>
        <style>
            html,
            body {
                height: 100%;
            }

            body {
                margin: 0;
                display: flex;
                justify-content: center;
                align-items: center;
            }

            form {
                text-align: right;
            }
        </style>
    </head>

    <body>
        <main>
            <h2>ユーザートップ</h2>
            @auth('web')
                <p>ログイン中</p>
            @endauth
            <form method="POST" action="{{ route('user.login.destroy')}}">
                @method('DELETE')
                @csrf
                <button type="submit">ログアウト</button>
            </form>
        </main>
    </body>
</html>
```

ここまで実装するとユーザー認証ができると思います。

## 管理の認証

マルチ認証するにあたって対応すること

- テーブルのsessions（ログイン状態管理）をユーザーと管理でそれぞれ保存できるように対応する

### モデル作成

```bash
php artisan make:model Admin -mf
```

オプションの`m`はマイグレーションファイル、`f`はファクトリーを作成

- app/Models/Admin.php

```diff_php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
-use Illuminate\Database\Eloquent\Model;
+use Illuminate\Foundation\Auth\User as Authenticatable;

-class Admin extends Model
+class Admin extends Authenticatable
{
    use HasFactory;
}
```

- database/migrations/YYYY_MM_DD_hhmmss_create_admins_table.php

管理IDもテーブルsessionsで管理

```diff_php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('admins', function (Blueprint $table) {
            $table->id();
+            $table->string('email')->unique()->comment('E-mail');
+            $table->string('name')->comment('名前');
+            $table->string('password')->comment('パスワード');
            $table->timestamps();
        });
+        Schema::table('sessions', function (Blueprint $table) {
+            $table->foreignId('admin_id')->after('user_id')->nullable()->index();
+        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
+        Schema::table('sessions', function (Blueprint $table) {
+            $table->dropIndex('sessions_admin_id_index');
+            $table->dropColumn('admin_id');
+        });
        Schema::dropIfExists('admins');
    }
};
```

- マイグレート実行

```bash
php artisan migrate
```

### seeder

- database/seeders/DatabaseSeeder.php

```diff_php
+use App\Models\Admin;

/***　略 ***/

        if (app()->isLocal()) {
            // 開発環境のみ
            User::factory()
                ->count(10)
                ->sequence(function ($sequence) {
                    return [
                        'name' => sprintf('user_%02d', $sequence->index + 1),
                        'email' => sprintf('user_%02d@example.com', $sequence->index + 1),
                        'password' => Hash::make(sprintf('user_%02d_password', $sequence->index + 1)),
                        'created_at' => new DateTime(),
                        'updated_at' => new DateTime(),
                    ];
                })
            ->create();
+            // 管理
+            Admin::factory()
+                ->count(10)
+                ->sequence(function ($sequence) {
+                    return [
+                        'name' => sprintf('admin_%02d', $sequence->index + 1),
+                        'email' => sprintf('admin_%02d@example.com', $sequence->index + 1),
+                        'password' => Hash::make(sprintf('admin_%02d_password', $sequence->index + 1)),
+                        'created_at' => new DateTime(),
+                        'updated_at' => new DateTime(),
+                    ];
+                })
+                ->create();
        }
```

- シーダー再登録のためマイグレートリフレッシュ

```bash
php artisan migrate:refresh --seed
```

### コントローラー作成

```bash
php artisan make:controller AdminLoginController
```

- app/Http/Controllers/AdminLoginController.php

```php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\AdminLoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

class AdminLoginController extends Controller
{
    /**
     * ログイン画面
     */
    public function create(): View
    {
        return view('admin.login');
    }

    /**
     * ログイン
     */
    public function store(AdminLoginRequest $request): RedirectResponse
    {
        $request->authenticate();
        $request->session()->regenerate();
        // sessionにurl.intendedがセットされていたら、その値へリダイレクトする
        // ない場合はadmin.topにリダイレクトする
        return redirect()->intended(route('admin.top'));
    }

    /**
     * ログアウト
     */
    public function destroy(Request $request): RedirectResponse
    {
        Auth::guard('admin')->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();
        return to_route('admin.login');
    }
}

```

### リクエスト作成

```bash
php artisan make:request AdminLoginRequest
```

- app/Http/Requests/AdminLoginRequest.php

```diff_php
    public function authorize(): bool
    {
-        return false;
+        return true;
    }

    /***　略 ***/

    public function rules(): array
    {
        return [
+            'email' => ['required', 'email'],
+            'password' => 'required',
        ];
    }

+    public function attributes(): array
+    {
+        return [
+            'email' => 'E-mail',
+            'password' => 'Password',
+        ];
+    }
+
+    public function messages(): array
+    {
+        return [
+            'email.required' => ':attributeを入力してください',
+            'email.email' => ':attributeが正しくありません',
+            'password.required' => ':attributeを入力してください',
+        ];
+    }
+
+    public function authenticate(): void
+    {
+        // attemptでログインを試みる
+        if (!Auth::guard('admin')->attempt($this->only(['email', 'password']))) {
+            throw ValidationException::withMessages(['failed' => __('auth.failed')]);
+        }
+    }
}
```

### ミドルウェア

- bootstrap/app.php

```diff_php
    $middleware->redirectGuestsTo(function(Request $request) {
+        if (request()->routeIs('admin.*')) {
+            return $request->expectsJson() ? null : route('admin.login');
+        }
        if (request()->routeIs('user.*')) {
            return $request->expectsJson() ? null : route('user.login');
        }
        return $request->expectsJson() ? null : route('auth');
    });
```

### ルーティング

- routes/web.php

```diff_php
+use App\Http\Controllers\AdminLoginController;

/***　略 ***/

+// 管理ログイン画面
+Route::get('/admin-login', [AdminLoginController::class, 'create'])->name('admin.login');
+// 管理ログイン
+Route::post('/admin-login', [AdminLoginController::class, 'store'])->name('admin.login.store');
+// 管理ログアウト
+Route::delete('/admin-login', [AdminLoginController::class, 'destroy'])->name('admin.login.destroy');
+
+// 管理ログイン後のみアクセス可
+Route::middleware('auth:admin')->group(function () {
+    Route::get('/admin-top', function () {
+        return view('admin.top');
+    })->name('admin.top');
+});
```

### ビュー

#### ログインView

```bash
php artisan make:view admin/login
```

- resources/views/admin/login.blade.php

```html
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>管理</title>
        <style>
            html,
            body {
                height: 100%;
            }

            body {
                margin: 0;
                display: flex;
                justify-content: center;
                align-items: center;
            }

            li {
                list-style: none;
            }

            form {
                text-align: right;
            }

            .alert {
                width: 300px;
                padding: 8px;
            }

            .alert ul {
                padding: 0;
            }

            .alert ul li {
                color: red;
                text-align: left;
                overflow-wrap: break-word;
            }
        </style>
    </head>

    <body>
        <main>
            <h2>管理ログイン</h2>
            <form method="POST" action="{{ route('admin.login.store') }}">
                @csrf
                <div>
                    <label for="email">E-main: </label>
                    <input type="text" id="email" name="email" required>
                </div>
                <div>
                    <label for="password">Password: </label>
                    <input type="password" id="password" name="password" required>
                </div>
                <div class="alert">
                    <ul>
                        @foreach ($errors->all() as $error)
                            <li>{{ $error }}</li>
                        @endforeach
                    </ul>
                </div>
                <button type="submit">ログイン</button>
            </form>
        </main>
    </body>
</html>

```

#### トップView

```bash
php artisan make:view admin/top
```

- resources/views/admin/top.blade.php

### 認証(guards, providers)設定

- config/auth.php

```diff_php
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
+        'admin' => [
+            'driver' => 'session',
+            'provider' => 'admins',
+        ],
    ],

    /***　略 ***/

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],
+        'admins' => [
+            'driver' => 'eloquent',
+            'model' => App\Models\Admin::class,
+        ],
    ],
```
### セッション管理

ユーザーでログインした場合と管理でログインした場合のID保存領域を分ける

- app/Session/AppDatabaseSessionHandler.php

sessionsテーブルへの保存カラムを分ける

```php
<?php

namespace App\Session;

use Illuminate\Contracts\Auth\Guard;
use Illuminate\Session\DatabaseSessionHandler;

class AppDatabaseSessionHandler extends DatabaseSessionHandler
{
    /**
     * Add the user information to the session payload.
     *
     * @param  array  $payload
     * @return $this
     */
    protected function addUserInformation(&$payload)
    {
        if ($this->container->bound(Guard::class)) {
            if (request()->routeIs('admin.*')) {
                $payload['admin_id'] = $this->userId();
            } else {
                $payload['user_id'] = $this->userId();
            }
        }

        return $this;
    }
}
```

プロバイダーにセッション管理を登録

ルートによってセッション名を変えることでそれぞれのセッションを扱えるようにする

- app/Providers/AppServiceProvider.php

```diff_php
+use App\Session\AppDatabaseSessionHandler;
+use Illuminate\Support\Facades\Session;

/***　略 ***/

    public function boot(): void
    {
        //
+        Session::extend('database', function ($app) {
+            $session_cookie = config('session.cookie');
+            if (request()->routeIs('admin.*')) {
+                config(['session.cookie' => $session_cookie . '_admin']);
+            } else {
+                config(['session.cookie' => $session_cookie . '_user']);
+            }
+
+            $connection_name = $this->app->config->get('session.connection');
+            $connection      = $app->app->db->connection($connection_name);
+            $table           = $this->app->config->get('session.table');
+            $lifetime        = $this->app->config->get('session.lifetime');
+
+            return new AppDatabaseSessionHandler($connection, $table, $lifetime, $this->app);
+        });
    }

```