【Laravel】メールアドレス+認証コードでモダンな認証機能を作成する

## 本記事のゴール
![画面収録 2023-09-25 7.13.16.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/219eb861-2277-f2f4-a595-b8ffb9f88adf.gif)


```mermaid
flowchart TB    
    welcome -->|ボタンクリック| first-auth -->|メールアドレス入力| second-auth -->|ワンタイムトークン入力| dashboard
```


## 開発環境
- M1 MacbookPro
- macOS Venture13.5.1
- Docker Deskctop

## 利用フレームワーク/パッケージ
- Laravel Sail
- Laravel 10
- Laravel Breeze


## 実装フロー
1. プロジェクトの初期設定
    1-1. [プロジェクト作成](#1-1プロジェクト作成)
    1-2. [エイリアスの設定](#1-2エイリアスの設定)
    1-3. [phpMyAdminの導入](#1-3phpmyadminの導入)
    1-4. [起動とパッケージインストール](#1-4起動とパッケージインストール)
    1-5. [migrateの実行](#1-5マイグレートの実行)
    1-6. [タイムゾーンの変更](#1-6タイムゾーンの変更)
    1-7. [mysqlの設定](#1-7mysqlの設定)

2. テーブル設定・初期ルーティング
    2-1. [migrationファイルを編集](#2-1migrationファイルを編集)
    2-2. [モデルファイルを修正](#2-2モデルファイルを修正)
    2-3. [welcomeページを修正](#2-3welcomeページを修正)
    2-4. [bladeファイルを作成](#2-4bladeファイルを作成)
    2-5. [初期ルーティング設定](#2-5初期ルーティング設定)
    2-6. [コントローラー設定](#2-6コントローラー設定)

3. メール送信のロジック作成
    3-1. [Mailableクラスを作成](#3-1mailableクラスを作成)
    3-2. [コントローラーの編集](#3-2コントローラーの編集)

4. ワンタイムトークン認証のロジック作成
    4-1. [トークンの正当性チェック](#4-1トークンの正当性チェック)
    4-2. [ルーティングを追加](#4-2ルーティングを追加)

## 1.プロジェクトの初期設定
Laravel Breezeのインストールまですでに終わっている方は
2-1. [migrationファイルを編集](#2-1migrationファイルを編集)こちらまでスキップしてください。
### 1-1.プロジェクト作成
任意の場所にプロジェクトを作成します。
```shell
cd dev
mkdir docker
cd docker
```
以下のコマンドでLaravelのプロジェクトを作成します
```shell
curl -s https://laravel.build/[任意のプロジェクト名] | bash
```
私は`Laravel-Qiita`というプロジェクト名で作成しました。
```shell
curl -s https://laravel.build/laravel-qiita | bash
```

### 1-2.エイリアスの設定
すでに設定してある方は無視してください。
```shell:~/.zshrc
alias sail='[ -f sail ] && bash sail || bash vendor/bin/sail'
```

### 1-3.phpmyadminの導入
プロジェクト内に移動し`docker-compose.yml`に追記します
```yml:/docker-compose.yml
services:
    laravel.test:
・・・省略
    phpmyadmin:
        image: phpmyadmin/phpmyadmin
        links:
            - mysql:mysql
        ports:
            - 8080:80
        environment:
            MYSQL_USER: "${DB_USERNAME}"
            MYSQL_PASSWORD: "${DB_PASSWORD}"
            PMA_HOST: mysql
        networks:
            - sail
```

### 1-4.起動とパッケージインストール
Sailの起動
```shell
sail up
```

これを実行し`localhost`にアクセスすると初期ページが表示されます。
![スクリーンショット 2023-09-23 2.09.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/3f3ba530-3959-5342-4191-093e32c96f1d.png)


パッケージインストール
```shall
sail npm install
```

ユーザ認証パッケージに`Laravel Breeze`を使用します。
```shell
sail composer require laravel/breeze --dev
sail artisan breeze:install
```
バージョンによって色々聞かれるみたいですがすべてデフォルトで進めました。(参考までに↓)
![スクリーンショット 2023-09-23 2.16.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/45bb0f3a-f9a1-7f5a-4155-cd0e67831e59.png)

js/cssのビルド
```shell
sail npm run dev
```
5173ポートでviteが起動します
![スクリーンショット 2023-09-23 2.33.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/aa95a4b0-b6e9-2d18-4983-b63a95e7dd64.png)

### 1-5.migrateの実行
```shell
sail artisan migrate
```
### 1-6.タイムゾーンの変更
ワンタイムトークンの有効期限の設定のため、日本時間に変更します。
```php:/config/app.php
'timezone' => 'Asia/Tokyo',
```

### 1-7.mysqlの設定
dbアクセスの際にエラーになる場合があるためstrictモードをオフにします。
```php:/config/database.php
'mysql' => [
            'driver' => 'mysql',
            'url' => env('DATABASE_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'prefix_indexes' => true,
            'strict' => false, //変更
            'engine' => null,
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
        ],
```

## 2.テーブル設定・初期ルーティング
### 2-1.migrationファイルを編集
カラムの追加処理を追記します。
```shell
sail artisan make:migration add_onetime_token_to_users_table --table=users
```

```php:/App/database/migrations/〇〇_add_onetime_token_to_users_table.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::table('users', function (Blueprint $table) {
            $table->string('name')->default('未設定')->change(); // nameカラムのnullを許可する
            $table->string('password')->nullable()->change(); // passwordカラムのnullを許可する
            $table->char("onetime_token", 4)->nullable(); // ワンタイムトークン
            $table->dateTime("onetime_expiration")->nullable(); // ワンタイムトークンの有効期限
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void {
        Schema::table('users', function (Blueprint $table) {
            $table->string('name')->default()->change();
            $table->string('password')->nullable(false)->change();
            $table->dropColumn("onetime_token");
            $table->dropColumn("onetime_expiration");
        });
    }
};

```

実行しておきます。
```shell
sail artisan migrate
```

### 2-2.モデルファイルを修正
変更可能なカラムにワンタイムトークンとその有効期限を追加します。
```php:/App/Models/User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable {
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'onetime_token', // 追加
        'onetime_expiration' // 追加
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
        'onetime_token', // 追加
        'onetime_expiration' // 追加
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];
}
```

### 2-3.welcomeページを修正
ログイン画面へのリンクを追加します。

```diff_php
<body class="antialiased">
        <div class="relative sm:flex sm:justify-center sm:items-center min-h-screen bg-dots-darker bg-center bg-gray-100 dark:bg-dots-lighter dark:bg-gray-900 selection:bg-red-500 selection:text-white">         
            <div class="sm:fixed sm:top-0 sm:right-0 p-6 text-right z-10">
                @auth
                    <a href="{{ url('/dashboard') }}" class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Dashboard</a>
                @else
                    @if (Route::has('auth.first-auth'))
                        <a href="{{ route('auth.first-auth') }}" class="ml-4 font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">ログイン / 新規登録</a>    
                    @endif
                @endauth
            </div>   
```

### 2-4.bladeファイルを作成
メール認証画面
```php:/resources/views/auth/first-auth.blade.php
<x-guest-layout>
    <form method="POST" action="{{ route('sendTokenEmail') }}">
        @csrf

        <!-- Email Address -->
        <div class="mt-4">
            <x-input-label for="email" :value="__('Email')" />
            <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required/>
            <x-input-error :messages="$errors->get('email')" class="mt-2" />
        </div>

        <div class="flex items-center justify-end mt-4">
            <x-primary-button class="ml-4">
                {{ __('認証コード送信') }}
            </x-primary-button>
        </div>
    </form>
</x-guest-layout>

```

ワンタイムトークン認証画面
```php:/resources/views/auth/second-auth.blade.php
<x-guest-layout>
    <form method="POST" action="{{ route('dashboard') }}">
        @csrf

        <!-- Email Address -->
        <div class="mt-4">
            <x-input-label for="onetime_token" :value="__('認証コード')" />
            <x-text-input id="onetime_token" class="block mt-1 w-full" type="number" name="onetime_token" :value="old('onetime_token')" required/>
            <x-input-error :messages="$errors->get('email')" class="mt-2" />
        </div>

        <div class="flex items-center justify-end mt-4">
            <x-primary-button class="ml-4">
                {{ __('ログイン / 新規登録') }}
            </x-primary-button>
        </div>
    </form>
</x-guest-layout>
```

### 2-5.初期ルーティング設定
一旦確認用にルーティングを設定します。
```php:auth.php
Route::middleware('guest')->group(function () {
    // Route::get('register', [RegisteredUserController::class, 'create'])
    //     ->name('register');

    /**
     **最初のメール入力画面を表示するルーティング
     */
    Route::get('first-auth', [RegisteredUserController::class, 'create'])
        ->name('auth.first-auth'); // 追加

    /**
     **トークンを含んだメールを送信するルーティング
     */
    Route::post('sendTokenEmail', [RegisteredUserController::class, 'sendTokenEmail'])
        ->name('sendTokenEmail');
・・・省略
}
```

### 2-6.コントローラー設定
ひとまず画面を表示させるのみです。
```php:/app/Http/Controllers/Auth/RegisteredUserController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;

class RegisteredUserController extends Controller {
    /**
     **最初の最初のメール入力画面を表示する
     */
    public function create(): View {
        return view('auth.first-auth');
    }

    /**
     **ワンタイムトークンが含まれるメールを送信する
     */
    public function sendTokenEmail(Request $request) {
        return view('auth.second-auth');
    }
・・・省略
});
```

ここまで設定するとこのような画面が表示されます。
![画面収録 2023-09-25 3.53.22.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/0e2bbf95-005d-7f73-6bc8-06efa42de37c.gif)
※撮影のため拡大してあります


## 3. メール送信のロジック作成
### 3-1.mailableクラスを作成
```shell
sail artisan make:mail TokenEmail 
```
```php:/app/Mail/TokenEmail.php
<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class TokenEmail extends Mailable {
    use Queueable, SerializesModels;
    private $email;
    private $onetime_token;

    /**
     * Create a new message instance.
     */
    public function __construct($email, $onetime_token) {
        $this->email = $email;
        $this->onetime_token = $onetime_token;
    }

    /**
     **メール作成
     */
    public function build() {
        return $this->to($this->email)
            ->subject("認証コード")
            ->view('auth.mail')
            ->with([
                'onetime_token' => $this->onetime_token
            ]);
    }
}
```

ついでにメール本文を表示するbladeファイルも作成しておきます
```php:/resources/views/auth/mail.blade.php
<h2>{{$onetime_token}}</h2>
```
### 3-2.コントローラーの編集
```php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
use App\Mail\TokenEmail;
use Illuminate\Support\Facades\Mail;

class RegisteredUserController extends Controller {
    /**
     **最初の最初のメール入力画面を表示する
     */
    public function create(): View {
        return view('auth.first-auth');
    }

    /**
     **引数で渡されたメールアドレスとワンタイムトークンをusersテーブルに追加するコントロール
     */
    public static function storeEmailAndToken($email, $onetime_token, $onetime_expiration) {
        User::create([
            'email' => $email,
            'onetime_token' => $onetime_token,
            'ontime_expiration' => $onetime_expiration
        ]);
    }

    /**
     **引数で渡されたワンタイムトークンをusersテーブルに追加するコントロール
     */
    public static function storeToken($email, $onetime_token, $onetime_expiration) {
        User::where('email', $email)->update([
            'onetime_token' => $onetime_token,
            'onetime_expiration' => $onetime_expiration
        ]);
    }

    /**
     **ワンタイムトークンが含まれるメールを送信する
     */
    public function sendTokenEmail(Request $request) {
        $email = $request->email;
        $onetime_token = "";

        for ($i = 0; $i < 4; $i++) {
            $onetime_token .= strval(rand(0, 9)); // ワンタイムトークン
        }
        $onetime_expiration = now()->addMinute(3); // 有効期限

        $user = User::where('email', $email)->first(); // 受け取ったメールアドレスで検索
        if ($user === null) {
            RegisteredUserController::storeEmailAndToken($email, $onetime_token, $onetime_expiration);
        } else {
            RegisteredUserController::storeToken($email, $onetime_token, $onetime_expiration);
        }

        session()->flash('email', $email); // 認証処理で利用するために一時的に格納
    
        Mail::send(new TokenEmail($email, $onetime_token));

        return view("auth.second-auth");
    }
}

```
ここまでできると、first-authの画面で`認証コード送信`を押した際に
`http://localhost:8025/`にメールが届くようになります。

![スクリーンショット 2023-09-25 6.12.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/777fad5a-52b2-c262-9ea9-44f42de478c2.png)
![スクリーンショット 2023-09-25 6.12.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/0a332cdf-8939-e2a2-a5fd-7b682cdef891.png)
![スクリーンショット 2023-09-25 6.13.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/8b083af0-3f07-fe40-5805-e8dec9571635.png)
![スクリーンショット 2023-09-25 6.13.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1814845/bbbfdc36-b27c-968f-021f-4b4aae2c8948.png)

## 4.ワンタイムトークンの認証ロジック
ここまできたらもう少しです！
### 4-1.トークンの正当性チェック
```php:/app/Http/Controllers/Auth/RegisteredUserController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
use App\Mail\TokenEmail;
use Carbon\Carbon;
use Illuminate\Support\Facades\Mail;

class RegisteredUserController extends Controller {
・・・省略
    /**
     **ワンタイムトークンが正しいか確かめてログインさせる
     */
    public function auth(Request $request): RedirectResponse {
        $user = User::where('email', session('email'))->first();
        $expiration = new Carbon($user['onetime_token']);

        if ($user['onetime_token'] == $request->onetime_token && $expiration > now()) {
            Auth::login($user);
            return redirect(RouteServiceProvider::HOME);
        }
        return redirect()->route('auth.first-auth');
    }
}

```

### 4-2.ルーティングを追加
```php:/routes/auth.php
<?php

use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\ConfirmablePasswordController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\EmailVerificationPromptController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    /**
     **最初のメール入力画面を表示するルーティング
     */
    Route::get('first-auth', [RegisteredUserController::class, 'create'])
        ->name('auth.first-auth'); // 追加

    /**
     **トークンを含んだメールを送信するルーティング
     */
    Route::post('sendTokenEmail', [RegisteredUserController::class, 'sendTokenEmail'])
        ->name('sendTokenEmail');
    
    /**
     **ワンタイムトークンが正しいか確かめてログインさせるルーティング
     */
    Route::post('login', [RegisteredUserController::class, 'auth'])
        ->name('login'); // 追加
});
・・・省略
```

## おわりに
とても長い記事となってしまいましたがご覧いただきありがとうございました。

今回はLaravel Breezeの認証機能を改造し、ワンタイムトークンによる認証を作成してみました。
参考にしていただくのは結構ですが、Breezeで用意されているものを変更して使っているため、セキュリティに関しては保証できかねます。

もし少しでも役に立った場合はぜひ♡をお願いします。