はじめに
現代において、もはやログイン認証の強化は必須と言っても過言ではないでしょう。
そこで今回は、サインイン時にユーザーのメールアドレス宛にコードを送信し、そのコードを入力しなければログインできない、2段階認証(2FA)の仕組みをLaravelで実装した際のコードを備忘録として記したいと思います。
前提条件
- メールアドレスの確認ロジックを導入済みであること
https://readouble.com/laravel/9.x/ja/verification.html
DBカラムの追加
2段階認証時に使用するコードや、コードの有効期限、2段階認証の利用有無を管理するカラムをusersテーブルへ追加します。
php artisan make:migration add_two_factor_auth_fields_to_users_table --table=users
<?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
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の登録
ルーターでアクセス制御するため、ミドルウェアを作成します。
php artisan make:middleware TwoFactorAuthenticate
2段階認証用のコードがDBに存在する場合は認証画面へ遷移し、存在しない場合は表示予定の画面へ遷移させます。
<?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
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
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段階認証のコードを送信するメールを作成します。
php artisan make:mail TwoFactorAuthenticationPasswordMail
DBへ登録した2段階認証用のコードを受け取り、メール本文に記載します。
<?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 [];
}
}
メール本文のテンプレートも作成します。
<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
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
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
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段階認証のフォーム画面を作成します。
@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段階認証を自前実装できますね。