Laravelで2段階認証（2FA）を実装する

# はじめに
現代において、もはやログイン認証の強化は必須と言っても過言ではないでしょう。

そこで今回は、サインイン時にユーザーのメールアドレス宛にコードを送信し、そのコードを入力しなければログインできない、2段階認証(2FA)の仕組みをLaravelで実装した際のコードを備忘録として記したいと思います。

## 前提条件
- メールアドレスの確認ロジックを導入済みであること
https://readouble.com/laravel/9.x/ja/verification.html

# DBカラムの追加
2段階認証時に使用するコードや、コードの有効期限、2段階認証の利用有無を管理するカラムをusersテーブルへ追加します。

```console
php artisan make:migration add_two_factor_auth_fields_to_users_table --table=users
```

```php:database/migrations/2024_01_01_000000_add_two_factor_auth_fields_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.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('tfa_token')->nullable()->comment('2段階認証コード')->after('remember_token');
            $table->timestamp('tfa_expires_at')->nullable()->comment('2段階認証コード有効期限日時')->after('tfa_token');
            $table->boolean('tfa_is_used')->default(false)->comment('2段階認証利用有無')->after('tfa_expires_at');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('tfa_token');
            $table->dropColumn('tfa_expires_at');
            $table->dropColumn('tfa_is_used');
        });
    }
};
```

カラムを追加したので、忘れずにModelも更新します。

```php:app/Models/User.php
<?php

namespace App\Models;

...

class User extends Authenticatable implements CanResetPassword, MustVerifyEmail
{
    use HasFactory, Notifiable, SoftDeletes;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        ...
        'tfa_token',
        'tfa_expires_at',
        'tfa_is_used',
    ];

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

# Middlewareの登録
ルーターでアクセス制御するため、ミドルウェアを作成します。

```console
php artisan make:middleware TwoFactorAuthenticate
```

2段階認証用のコードがDBに存在する場合は認証画面へ遷移し、存在しない場合は表示予定の画面へ遷移させます。

```php:app/Http/Middleware/TwoFactorAuthenticate.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class TwoFactorAuthenticate
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @param  string|null  ...$guards
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next, ...$guards)
    {
        // 2段階認証利用なしの場合は認証不要
        if (!Auth::user()->tfa_is_used) {
            return $next($request);
        }

        // トークンクリア済みの場合は認証不要
        if (!Auth::user()->tfa_token) {
            return $next($request);
        }

        return redirect()->route('tfa.login');
    }
}
```

作成したミドルウェアをKernelに登録します。

```php:app/Http/Kernel.php
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    /**
     * The application's route middleware.
     *
     * These middleware may be assigned to groups or used individually.
     *
     * @var array<string, class-string|string>
     */
    protected $routeMiddleware = [
        ...
        'tfAuth' => \App\Http\Middleware\TwoFactorAuthenticate::class,
    ];
}
```

# Routerの更新
追加したミドルウェアをルーターに追加します。

元々は認証前が`guest`、認証後が`auth`で管理されていたところを、
認証前が`guest`、1段階認証後が`auth`、2段階認証後が`auth`と`tfAuth`となるように変更します。

```php:routes/auth.php
<?php

use Illuminate\Support\Facades\Route;

Route::middleware(['guest'])->group(function () {
    // サインイン前の画面（会員登録やログイン、パスワードリマインドなど）
    ...
});

Route::middleware(['auth'])->group(function () {
    // サインイン後の画面(メール確認の通知やログアウトなど)
    ...
});

Route::middleware(['auth', 'verified', 'tfAuth'])->group(function () {
    // 2段階認証後の画面（マイページなど）
    ...
});
```

# Mailの登録
2段階認証のコードを送信するメールを作成します。

```console
php artisan make:mail TwoFactorAuthenticationPasswordMail
```

DBへ登録した2段階認証用のコードを受け取り、メール本文に記載します。

```php:app/Mail/TwoFactorAuthenticationPasswordMail.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 TwoFactorAuthenticationPasswordMail extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Token
     *
     * @var string
     */
    private string $tfa_token = '';

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct($tfa_token)
    {
        $this->tfa_token = $tfa_token;
    }

    /**
     * Get the message envelope.
     *
     * @return \Illuminate\Mail\Mailables\Envelope
     */
    public function envelope()
    {
        return new Envelope(
            subject: '【' . config('app.name') . '】 2段階認証コード',
        );
    }

    /**
     * Get the message content definition.
     *
     * @return \Illuminate\Mail\Mailables\Content
     */
    public function content()
    {
        return new Content(
            markdown: 'emails.two-factor-authentication',
            with: [
                'tfa_token' => $this->tfa_token,
            ],
        );
    }

    /**
     * Get the attachments for the message.
     *
     * @return array
     */
    public function attachments()
    {
        return [];
    }
}
```

メール本文のテンプレートも作成します。

```php:resources/views/emails/two-factor-authentication.blade.php
<x-mail::message>
# Hello

2段階認証用のコードをお送りします。<br>
下記コードをコピーしてログインしてください。

@component('mail::panel')
{{ $tfa_token }}
@endcomponent

このパスワードは 10 分で期限切れになります。

<br>

@component('mail::subcopy')
こちらは自動配信メールになります。このメールには返信できません。
@endcomponent

</x-mail::message>
```

# Controllerの登録
2段階認証のコードを生成したり、認証画面を表示する制御を行うコントローラーを作成します。
2段階認証コードの送信関数では、メールアドレスが認証済みかどうかのチェックもしています。

今回はコードの有効期限を10分で固定にしていますが、`.env`ファイルで管理できるようにしても良いですね。

## TwoFactorAuthenticationController.php
```php:app/Http/Controllers/Auth/TwoFactorAuthenticationController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Mail\TwoFactorAuthenticationPasswordMail;
use App\Providers\RouteServiceProvider;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;

class TwoFactorAuthenticationController extends Controller
{
    /**
     * 2段階認証画面表示
     * 
     * @param Request $request
     * @return View
     */
    public function create(Request $request): RedirectResponse|View
    {
        // 2段階認証利用なしの場合は認証不要
        if (!$request->user()->tfa_is_used) {
            return redirect(RouteServiceProvider::HOME);
        }

        return view('auth.login-tfa');
    }

    /**
     * 2段階認証処理
     * 
     * @param Request $request
     * @return RedirectResponse
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'token' => ['required', 'string'],
        ]);

        $tfa_token = $request->user()->tfa_token;
        $tfa_expires_at = new Carbon($request->user()->tfa_expires_at);

        if ($request->token === $tfa_token && $tfa_expires_at > now()) {
            // 認証OK
            $request->user()->update([
                'tfa_token' => null,
                'tfa_expires_at' => null,
            ]);

            return redirect()->intended(RouteServiceProvider::HOME);
        } else {
            // 認証NG
            return back()->withInput($request->only('token'))
                            ->withErrors(['token' => __('auth.tfa.token')]);
        }
    }

    /**
     * 2段階認証コードの送信
     * 
     * @param Request $request
     * @return RedirectResponse
     */
    public function notice(Request $request): RedirectResponse
    {
        if ($request->user()->tfa_is_used
            && $request->user()->hasVerifiedEmail()
        ) {
            // 6桁のランダムな数字を生成
            $random_password = '';
            for ($i = 0; $i < 6; $i++) {
                $random_password .= strval(rand(0, 9));
            }
            // 10分後の時間を取得
            $expiresAt = now()->addMinutes(10);

            $request->user()->update([
                'tfa_token' => $random_password,    // 2段階認証コード
                'tfa_expires_at' => $expiresAt,     // 2段階認証コード有効期限
            ]);

            // メール送信
            Mail::to($request->user()->email)->send(new TwoFactorAuthenticationPasswordMail($random_password));

            return redirect()->route('tfa.login');
        }

        return redirect()->intended(RouteServiceProvider::HOME);
    }
}
```

同じく、サインイン処理をしている`AuthenticatedSessionController`でも2段階認証コードを送信するように更新します。

## AuthenticatedSessionController.php
```php:app/Http/Controllers/Auth/AuthenticatedSessionController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Mail\TwoFactorAuthenticationPasswordMail;
...

class AuthenticatedSessionController extends Controller
{
    /**
     * Handle an incoming authentication request.
     */
    public function store(LoginRequest $request): RedirectResponse
    {
        $request->authenticate();

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

        // 2段階認証
        if ($request->user()->tfa_is_used
            && $request->user()->hasVerifiedEmail()
        ) {
            // 6桁のランダムな数字を生成
            $random_password = '';
            for ($i = 0; $i < 6; $i++) {
                $random_password .= strval(rand(0, 9));
            }
            // 10分後の時間を取得
            $expiresAt = now()->addMinutes(10);

            $request->user()->update([
                'tfa_token' => $random_password,    // 2段階認証コード
                'tfa_expires_at' => $expiresAt,     // 2段階認証コード有効期限
            ]);

            Mail::to($request->user()->email)->send(new TwoFactorAuthenticationPasswordMail($random_password));
        }

        return redirect()->intended(RouteServiceProvider::HOME);
    }

    ...
}
```

# 再びのRouterの更新
作成した画面をルーターに追加します。

```php:routes/auth.php
<?php

use App\Http\Controllers\Auth\TwoFactorAuthenticationController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth'])->group(function () {
    // サインイン後の画面(メール確認の通知やログアウトなど)
    ...

    // 2段階認証
    Route::get('login-tfa', [TwoFactorAuthenticationController::class, 'create'])
                ->name('tfa.login');
    Route::post('login-tfa', [TwoFactorAuthenticationController::class, 'store']);
    Route::get('verify-tfa', [TwoFactorAuthenticationController::class, 'notice'])
                ->name('tfa.notice');
});
```


# Bladeの登録
最後に、ログイン画面を参考に2段階認証のフォーム画面を作成します。

```php:resources/views/auth/login-tfa.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-10">
            <div class="card">
                <div class="card-header">{{ __('Login') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('login-tfa') }}">
                        @csrf

                        <div class="form-group row">
                            <label for="token" class="col-md-4 col-form-label text-md-right">認証コード</label>

                            <div class="col-md-6">
                                <input id="token" type="password" class="form-control{{ $errors->has('token') ? ' is-invalid' : '' }}" name="token" required>

                                @if ($errors->has('token'))
                                    <span class="invalid-feedback">
                                        <strong>{{ $errors->first('token') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row mb-4">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    ログイン
                                </button>

                                <a class="btn btn-link" href="{{ url('verify-tfa') }}">
                                    認証コードの再発行
                                </a>
                            </div>
                        </div>

                        <p class="text-center mb-3">
                            <a class="btn btn-link" href="{{ url('logout') }}">
                                ログアウト
                            </a>
                        </p>

                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
```

# おわりに
Laravelであれば、簡単に2段階認証を自前実装できますね。