🐘

Laravel Breeze + Google Authenticatorで2段階認証を設定する

2023/08/24に公開

久しぶりの PHP 関連の記事です。

TL;DR

最近 pictBrand,pictSQUARE でのパスワードや口座流出事件を受けて、サブカル業界のエンジニアたちにおかれましてはセキュリティ意識が高まっていることと思います。

https://togetter.com/li/2206093

自分も例外ではないのですが、ふと「Google Authenticator で 2 段階認証を実装するのってどうするんだろう?」と疑問が浮かびました。

ということで、今回は勝手知ったる Laravel Breeze と Google 2FA を利用して 2 段階認証を実装します。

ちなみに今回対応分の PR はこちらです。

https://github.com/ysknsid25/otaku-tool/pull/61

前提

  • Laravel 10
  • Laravel Breeze 1.2
  • google2fa 2.1

google2fa ドキュメント

google2fa のリポジトリにある README に詳しく設定方法が書かれています。

https://github.com/antonioribeiro/google2fa

google2fa と bacon/bacon-qr-code のインストール

2 段階認証と QR コード生成に必要なライブラリをインストールします。

composer require pragmarx/google2fa-laravel --dev
composer require bacon/bacon-qr-code --dev

google2fa の config 作成

php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"

これでconfig/google2fa.phpが作成されます。

認証コード判定用のカラムと、2 段階認証の有効/無効を管理するカラムを User テーブルに追加する

migration ファイルを作成していく

php artisan make:migration add_goole2fa_columns_to_users_table --table=users

作成された migration ファイルを以下のように修正。

add_goole2fa_columns_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('google2fa_secret')
+                ->nullable()
+                ->after('remember_token');
+            $table->string('is_enable_google2fa')
+                ->nullable()
+                ->after('google2fa_secret');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
-            //
+            $table->dropColumn('google2fa_secret');
+            $table->dropColumn('is_enable_google2fa');
        });
    }
};

準備ができたら migrate

php artisan migrate:refresh --step=1 --path=database/migrations/2023_08_23_120301_add_goole2fa_columns_to_users_table.php

データベースを確認

desc users;

google2fa_secretis_enable_google2faが追加されていれば OK

User モデルの修正

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;
+ use Illuminate\Database\Eloquent\Casts\Attribute;

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

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

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

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

+    /**
+     * Interact with the user's first name.
+     *
+     * @param  string  $value
+     * @return \Illuminate\Database\Eloquent\Casts\Attribute
+     */
+    protected function google2faSecret(): Attribute
+    {
+        return new Attribute(
+            get: fn ($value) =>  decrypt($value),
+            set: fn ($value) =>  encrypt($value),
+        );
+    }
}

Middleware の設定

Middleware を登録

Http/Kernel.php
    /**
     * The application's middleware aliases.
     *
     * Aliases may be used to conveniently assign middleware to routes and groups.
     *
     * @var array<string, class-string|string>
     */
    protected $middlewareAliases = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
        'signed' => \App\Http\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
+        'google2fa' => \PragmaRX\Google2FALaravel\Middleware::class,
    ];

Route の修正

登録した Middleware を Route に設定します。

<?php

use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProgramController;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

-Route::get('/dashboard', function () {
-    return view('dashboard');
- })->middleware(['auth', 'verified'])->name('dashboard');

+Route::middleware(['auth', 'verified', 'google2fa'])->group(function () {
+    Route::get('/dashboard', function () {
+        return view('dashboard');
+    })->name('dashboard');
+    Route::get('/2fa', function () {
+        return redirect(route('dashboard'));
+    })->name('2fa');
+    Route::post('/2fa', function () {
+        return redirect(route('dashboard'));
+    })->name('2fa');
+});

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});

Route::middleware('auth')->group(function () {
    Route::get('/programs', [ProgramController::class, 'index'])->name('programs.index');
    Route::post('/programs', [ProgramController::class, 'show'])->name('programs.show');
    Route::patch('/programs', [ProgramController::class, 'update'])->name('programs.update');
});

require __DIR__ . '/auth.php';

アカウント登録のカスタマイズ

画面の修正

register.blade.php
<x-guest-layout>
    <form method="POST" action="{{ route('register') }}">
        @csrf

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

        <!-- 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 autocomplete="username" />
            <x-input-error :messages="$errors->get('email')" class="mt-2" />
        </div>

        <!-- Password -->
        <div class="mt-4">
            <x-input-label for="password" :value="__('Password')" />

            <x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required
                autocomplete="new-password" />

            <x-input-error :messages="$errors->get('password')" class="mt-2" />
        </div>

        <!-- Confirm Password -->
        <div class="mt-4">
            <x-input-label for="password_confirmation" :value="__('Confirm Password')" />

            <x-text-input id="password_confirmation" class="block mt-1 w-full" type="text"
                name="password_confirmation" required autocomplete="new-password" />

            <x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
        </div>

+        <!-- is Enable Google2fa -->
+        <div class="block mt-4">
+            <label for="is_enable_google2fa" class="inline-flex items-center">
+                <input id="is_enable_google2fa" type="checkbox"
+                    class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="is_enable_google2fa" value="1">
+                <span class="ml-2 text-sm text-gray-600">{{ __('Enable Google 2fa') }}</span>
+            </label>
+        </div>

        <div class="flex items-center justify-end mt-4">
            <a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                href="{{ route('login') }}">
                {{ __('Already registered?') }}
            </a>

            <x-primary-button class="ml-4">
                {{ __('Register') }}
            </x-primary-button>
        </div>
    </form>
</x-guest-layout>

コントローラーの修正

auth/RegisteredUserController.php
    /**
     * Handle an incoming registration request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:' . User::class],
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
        ]);

+        $google2fa = app('pragmarx.google2fa');
+        $google2faSecret = "";
+        if (!is_null($request->is_enable_google2fa)) {
+            $google2faSecret = $google2fa->generateSecretKey();
+        }

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
+            'google2fa_secret' => $google2faSecret,
+            'is_enable_google2fa' => $request->is_enable_google2fa,
        ]);

        event(new Registered($user));

        Auth::login($user);

        return redirect(RouteServiceProvider::HOME);
    }

PragmaRX\Google2FALaravel\Middleware のカスタマイズ

常に判定するわけではなく、User テーブルのis_enable_google2fanullでない(2 段階認証が有効な)場合のみログイン時に判定するように修正します。

google2fa-laravel/src/Middleware.php
<?php

namespace PragmaRX\Google2FALaravel;

use Closure;
use PragmaRX\Google2FALaravel\Support\Authenticator;
+use Illuminate\Support\Facades\Auth;

class Middleware
{
    public function handle($request, Closure $next)
    {
        $authenticator = app(Authenticator::class)->boot($request);

+        //! 2段階認証が無効なのであれば、認証不要
+        if (is_null(Auth::user()->is_enable_google2fa)) {
+            return $next($request);
+        }

        if ($authenticator->isAuthenticated()) {
            return $next($request);
        }

        return $authenticator->makeRequestOneTimePasswordResponse();
    }
}

2 段階認証画面の作成

<x-guest-layout>
    <!-- Session Status -->
    <x-auth-session-status class="mb-4" :status="session('status')" />
    @csrf
    <div class="grid grid-cols-1 gap-16">
        @php
            $qrCodeUrl = Google2FA::getQRCodeInline(config('app.name'), Auth::user()->email, Auth::user()->google2fa_secret);
        @endphp
        <div class="flex items-center justify-center">
            {!! $qrCodeUrl !!}
        </div>
        <div class="mt-4">
            <p>2要素認証には、スマホのGoogle Authenticatorアプリが必要です。以下からあなたのデバイスに合わせてインストールしてください。</p>
        </div>
        <div class="flex items-center justify-center gap-4 mt-4">
            <div>
                <a
                    href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=ja&gl=US">GooglePlay</a>
            </div>
            <div>
                <a href="https://apps.apple.com/jp/app/google-authenticator/id388497605">AppStore</a>
            </div>
        </div>
        <div class="mt-4">
            <p class="text-sm mb-2">アプリに表示されている文字列を入力してください。30秒ごとに変わります。</p>
        </div>
        <form method="POST" action="{{ route('2fa') }}">
            <div class="mt-4">
                <input type="text" id="one_time_password" name="one_time_password"
                    class="block w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
            </div>

            <!-- Validation Errors -->
            @if ($errors->any())
                <x-input-error :messages="$errors->first()" />
            @endif

            <div class="mt-4 flex items-center justify-end">
                <div>
                    <x-primary-button>
                        {{ __('Log in') }}
                    </x-primary-button>
                </div>
            </div>
        </form>
        <div class="mt-4 flex items-center justify-start">
            <div>
                <form method="POST" action="{{ route('logout') }}">
                    @csrf
                    <a href="route('logout')"
                        onclick="event.preventDefault();
                                        this.closest('form').submit();">
                        {{ __('Log Out') }}
                    </a>
                </form>
            </div>
        </div>
    </div>

</x-guest-layout>

ここで重要なのはログアウトリンクを用意しておくことです。

なぜかというと、1 段階目で認証されたあとの 2 段階目の認証で、「Authenticator がわからずやっぱり別アカウントで認証したい」という場合にも永遠に middleware が効いてしまうからです。

その場合は一旦ログアウトで 1 段階目の認証情報を破棄し、再度 1 段階目の認証からやり直す必要があります。

アカウント登録からテストする

認証メールを確認

メール認証後、ログイン画面へ

初回は QR コードを読み込んで Authenticator に登録が必要です

そうすると Google Authenticator 側に 1TP が表示されるようになりました。

ではまずは出鱈目なパスワードを打ち込んでみます。

無事に怒ってくれました。

ではただしいパスワードを入れてみると、、、

無事にログインすることができました。

プロフィールから 2 段階認証を設定できるようにする

今回はProfile Informationと一緒に更新できるようにしてみます。

あくまで検証のための実装なので、本番であれば 2 段階認証の設定をいじる前には再度パスワードを入力させてからの方が安全かなと。

パスワードの比較は Breeze のコードとかを見てみるとわかると思います。

たぶんこのあたり。

https://zenn.dev/bs_kansai/articles/5ac107b0f1cd7d#8.-auth%2Fsessionguard.php-から呼ばれる-attempt-メソッド

画面の修正

profile/partials/update-profile-information-form.blade.php
<section>
    <header>
        <h2 class="text-lg font-medium text-gray-900">
            {{ __('Profile Information') }}
        </h2>

        <p class="mt-1 text-sm text-gray-600">
            {{ __("Update your account's profile information and email address.") }}
        </p>
    </header>

    <form id="send-verification" method="post" action="{{ route('verification.send') }}">
        @csrf
    </form>

    <form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
        @csrf
        @method('patch')

        <div>
            <x-input-label for="name" :value="__('Name')" />
            <x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)"
                required autofocus autocomplete="name" />
            <x-input-error class="mt-2" :messages="$errors->get('name')" />
        </div>

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

            @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && !$user->hasVerifiedEmail())
                <div>
                    <p class="text-sm mt-2 text-gray-800">
                        {{ __('Your email address is unverified.') }}

                        <button form="send-verification"
                            class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                            {{ __('Click here to re-send the verification email.') }}
                        </button>
                    </p>

                    @if (session('status') === 'verification-link-sent')
                        <p class="mt-2 font-medium text-sm text-green-600">
                            {{ __('A new verification link has been sent to your email address.') }}
                        </p>
                    @endif
                </div>
            @endif
        </div>

+        <!-- is Enable Google2fa -->
+        <div>
+            <label for="is_enable_google2fa" class="inline-flex items-center">
+                <input id="is_enable_google2fa" type="checkbox"
+                    class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
+                    name="is_enable_google2fa" value="1" {{ $user->is_enable_google2fa ? 'checked' : '' }}>
+                <span class="ml-2 text-sm text-gray-600">{{ __('Enable Google 2fa') }}</span>
+            </label>
+        </div>

        <div class="flex items-center gap-4">
            <x-primary-button>{{ __('Save') }}</x-primary-button>

            @if (session('status') === 'profile-updated')
                <p x-data="{ show: true }" x-show="show" x-transition x-init="setTimeout(() => show = false, 2000)"
                    class="text-sm text-gray-600">{{ __('Saved.') }}</p>
            @endif
        </div>
    </form>
</section>

今は 2 段階認証が有効なので、チェックが入った状態で表示されました。

コントローラーの修正

ProfileController.php
    /**
     * Update the user's profile information.
     */
    public function update(ProfileUpdateRequest $request): RedirectResponse
    {
-        $request->user()->fill($request->validated());
+        $request->validated();
+        $request->user()->fill(
+            [
+                'name' => $request->name,
+                'email' => $request->email,
+                'is_enable_google2fa' => $request->is_enable_google2fa,
+            ]
+        );

        if ($request->user()->isDirty('email')) {
            $request->user()->email_verified_at = null;
        }

        $request->user()->save();

        return Redirect::route('profile.edit')->with('status', 'profile-updated');
    }

無効にしてみる

では無効にしてみます。

ログアウトして再ログインすると、、、

今度はそのままダッシュボードへ遷移しました。

おわりに

3,4 時間ほどでここまで実装できたので、意外と簡単でした。

「アプリを間違えて消しちゃって〜」といったパターンまでは検証してないので、そのあたりに関してはまたいずれ。

そうしたリカバリーをするとなると、

  1. 認証済みのメールに対してリンクと生存期間が 1 分とかのコードを送信
  2. コードを画面に入力
  3. google2fa_secret を再度生成する必要がある

というような流れが必要そうです。そして前提として、

  • メールが認証済でなければ 2 段階認証は使用できない
  • 初期化のためにコードを入力する画面でメール再送できる(ただし回数制限付き)

というようなことが必要そうです。

これができれば、認証済メールアドレスを使った 2 段階認証も実装できそうです。

メンバー募集中!

サーバーサイド Kotlin コミュニティを作りました!

Kotlin ユーザーはぜひご参加ください!!

https://serverside-kt.connpass.com/

また関西在住のソフトウェア開発者を中心に、関西エンジニアコミュニティを一緒に盛り上げてくださる方を募集しています。

よろしければ Conpass からメンバー登録よろしくお願いいたします。

https://blessingsoftware.connpass.com/

blessing software

Discussion