【Laravel】メールを使った2段階認証を実装する

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、前回記事では「自由研究」的な記事を公開させてもらったので、今回は通常通りの流れに戻してLaravelの話題をお届けします。

なお、今回の話題は数カ月ほど前に思いついたものですが、実は「ある間を騒がせた」ある決済系サービスのニュースが元になっていたので、皆さんに「ははーん、あのニュースの影響を受けたね😂」って思われたくなかったので、少しばかり寝かせてました(笑)

で、その内容とはズバリ、

2段階認証

です。

毎日ニュースがたくさん出てくるので、あまり覚えてない方も多いかもしれませんが、この機能を作っていなかったために、勝手に他人に自分のお金を使われてしまったという事件がありました。杜撰なシステムだったのは間違いないですが、やはりセキュリティって1つ間違うととんでもない結果を引き起こすということを再認識した出来事でした。

そこで!

今回はLaravelでメールを使った2段階認証システムを実装する方法をお届けしたいと思います。

ぜひ皆さんのお役に立てると嬉しいです😊✨
(最後に実際に開発したソースコードをダウンロードできます)

開発環境: Laravel 6.x

前提として

今回の記事は、Laravelのログイン機能がすでにインストールされていることを前提としています。

そのため、もしインストールがまだの方は以下を参考にして準備しておいてください。

では、はりきっていきましょう!

2段階認証のフィールドをusersテーブルに追加する

まず2段階認証に必要な以下2つのフィールドをDBテーブルusersに追加します。

  • メールで送信する2段階目のワンタイム・パスワード
  • ワンタイム・パスワードの有効期限

以下のコマンドを実行してマイグレーション・ファイルを作成してください。

php artisan make:migration add_two_factor_auth_fields_to_users

すると、database/migrations/****_**_**_******_add_two_factor_auth_fields_to_users.phpが作成されますので、開いて中身を以下のようにします。

<?php

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

class AddTwoFactorAuthFieldsToUsers extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('tfa_token')
                ->nullable()
                ->after('remember_token');
            $table->dateTime('tfa_expiration')
                ->nullable()
                ->after('tfa_token');
        });
    }

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

※ ちなみにtfa_というプリフィックスはtwo factor authの略です。

ファイルが完成したらマイグレーションを実行しておきましょう。

php artisan migrate

マイグレーションが完了すると以下のようになります。

ルートをつくる

次にブラウザやAjaxでアクセスするURLを作ります。

Route::get('two_factor_auth/login_form', 'TwoFactorAuthController@login_form');
Route::post('ajax/two_factor_auth/first_auth', 'TwoFactorAuthController@first_auth');
Route::post('ajax/two_factor_auth/second_auth', 'TwoFactorAuthController@second_auth');

上から、

  • ログインフォーム
  • 1段階目の認証
  • 2段階目の認証

のルートになります。

パスワードを送信するメール機能をつくる

さらに、次の項目で必要になってくるので、ワンタイム・パスワードを送信するメール機能を先に作っておきましょう。

以下のコマンドを実行してください。

php artisan make:mail TwoFactorAuthPassword

すると、app/Mail/TwoFactorAuthPassword.phpが作成されるので、開いて以下のようにします。

<?php

namespace App\Mail;

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

class TwoFactorAuthPassword extends Mailable
{
    use Queueable, SerializesModels;

    private $tfa_token = '';

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

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build()
    {
        return $this->from('test@example.com', 'サイト名')
            ->subject('2段階認証のパスワード')
            ->view('emails.two_factor_auth.password')
            ->with('tfa_token', $this->tfa_token);
    }
}

【追記:2020.02.10】訪問ユーザー様のご指摘により太字部分を修正しました。皆様、いつもありがとうございます。m_ _m

そして、まだメールのビューは存在していないので、resources/views/emails/two_factor_auth/password.blade.phpというファイルをつくります。

中身はこうなります。

2段階認証のパスワードをご送付します。<br>

【パスワード】
{{ $tfa_token }}

これで、もしメールが送信されると以下のような内容が送付されることになります。

コントローラーをつくる

では、2段階認証のメインになる部分、コントローラーをつくっていきます。

まずは以下のコマンドでコントローラーをつくります。

php artisan make:controller TwoFactorAuthController

app/Http/Controllers/TwoFactorAuthController.phpというファイルが作成されるので、中身を以下のようにします。

<?php

namespace App\Http\Controllers;

use App\Mail\TwoFactorAuthPassword;
use Carbon\Carbon;
use Illuminate\Http\Request;

class TwoFactorAuthController extends Controller
{
    public function login_form() {  // ログインフォーム

        return view('two_factor_auth.login_form');

    }

    public function first_auth(Request $request) {  // 1段階目の認証

        $credentials = $request->only('email', 'password');

        if(\Auth::validate($credentials)) {

            $random_password = '';

            for($i = 0 ; $i < 4 ; $i++) {

                $random_password .= strval(rand(0, 9));

            }

            $user = \App\User::where('email', $request->email)->first();
            $user->tfa_token = $random_password;            // 4桁のランダムな数字
            $user->tfa_expiration = now()->addMinutes(10);  // 10分間だけ有効
            $user->save();

            // メール送信
            \Mail::to($user)->send(new TwoFactorAuthPassword($random_password));

            return [
                'result' => true,
                'user_id' => $user->id
            ];

        }

        return ['result' => false];

    }

    public function second_auth(Request $request) {  // 2段階目の認証

        $result = false;

        if($request->filled('tfa_token', 'user_id')) {

            $user = \App\User::find($request->user_id);
            $expiration = new Carbon($user->tfa_expiration);

            if($user->tfa_token === $request->tfa_token && $expiration > now()) {

                $user->tfa_token = null;
                $user->tfa_expiration = null;
                $user->save();

                \Auth::login($user);    // 自動ログイン
                $result = true;

            }

        }

        return ['result' => $result];

    }
}

【追記:2020.10.22】訪問ユーザーさんにご指摘をいただき、コードが間違っていたので修正しました。修正箇所は、Auth::attempt()Auth::validate()です。
いつもご協力ありがとうございます。m(_ _)m

この中での流れは次のとおりです。

  1. ログインフォームでメールアドレスとパスワードを送信
  2. 合っていれば、2段階認証のワンタイムパスワードをメールで送信
  3. 2段階認証のパスワードも合っていれば、そのユーザーをログインさせる

なお、first_auth()second_auth()Ajaxでアクセスされることになります。

ビュー(ログインフォーム)をつくる

では、最後に先ほどつくったコントローラーと連携するログイン・フォームのビューをつくっていきましょう。ファイルのパスは、/views/two_factor_auth/login_form.blade.phpです。

<html>
<head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div id="app" class="p-5">
        <div class="alert alert-info" v-if="message" v-text="message"></div>

        <!-- 1段階目のログインフォーム -->
        <div v-if="step==1">
            <div class="form-group">
                <label>メールアドレス</label>
                <input type="text" class="form-control" v-model="email">
            </div>
            <div class="form-group">
                <label>パスワード</label>
                <input type="password" class="form-control" v-model="password">
            </div>
            <button type="button" class="btn btn-primary" @click="firstAuth">送信する</button>
        </div>

        <!-- 2段階目・ログインフォーム -->
        <div v-if="step==2">
            2段階認証のパスワードをメールアドレスに登録しました。(有効時間:10分間)
            <hr>
            <div class="form-group">
                <label>2段階パスワード</label>
                <input type="text" class="form-control" v-model="token">
            </div>
            <button type="button" class="btn btn-primary" @click="secondAuth">送信する</button>
        </div>

    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.0"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.0/axios.min.js"></script>
    <script>

        new Vue({
            el: '#app',
            data: {
                step: 1,
                email: '',
                password: '',
                token: '',
                userId: -1,
                message: ''
            },
            methods: {
                firstAuth() {

                    this.message = '';

                    const url = '/ajax/two_factor_auth/first_auth';
                    const params = {
                        email: this.email,
                        password: this.password
                    };
                    axios.post(url, params)
                        .then(response => {

                            const result = response.data.result;

                            if(result) {

                                this.userId = response.data.user_id;
                                this.step = 2;

                            } else {

                                this.message = 'ログイン情報が間違っています。';

                            }

                        });

                },
                secondAuth() {

                    const url = '/ajax/two_factor_auth/second_auth';
                    const params = {
                        user_id: this.userId,
                        tfa_token: this.token
                    };

                    axios.post(url, params)
                        .then(response => {

                            const result = response.data.result;

                            if(result) {

                                // 2段階認証成功
                                location.href = '/home';

                            } else {

                                this.message = '2段階パスワードが正しくありません。';
                                this.token = '';

                            }

                        });

                }
            }
        });

    </script>
</body>
</html>

先ほど説明した流れに沿ってAjax送信しているだけなので、コードは少し長いですが内容としてはシンプルかと思います。

なお、2段階認証が成功すると「2段階認証成功」と書かれている部分でリダイレクトをするようにしていますので、お好みで変更してください。

テストしてみる

では、実際にテストしてみます。

まずは、ログインフォームにメールアドレスとパスワードを入力します。

送信ボタンをクリックすると、メールに2段階認証のパスワードが送信されます。

ここに書かれているパスワードを自動的に切り替わったログインフォームに入力します。

送信ボタンをクリックすると、ログインが実行されリダイレクトされます。

ログインがうまくいっているので、ページ右上に名前とログアウトリンクが表示されるようになりました。

成功です😊✨

ダウンロードする

以下から今回実際に開発したソースコード一式をダウンロードできます。

※ ただし、マイグレーションなどはご自身で実行していただく必要があります。また、このソースコードをカスタマイズする開発などもご依頼いただけますので、その際はお問い合わせからご連絡ください。

【追記:2020.02.10】app/Mailフォルダが抜けていたので追加しました。どうもすみません😅

【Laravel】メールを使った2段階認証
開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はLaravel + メールの2段階認証をつくってみました。有効期限が10分になっているので、ある程度は強力な認証システムになっているかと思います。

正直なところ、毎回この2段階認証をさせられるのはめんどうでしかたないですが、例えば「たまにしか使わないけど、とても重要なデータ」を扱うシステムには必要なものといっていいでしょう。

また、今回の機能を応用すればLINEのようにQRコードを読みとってもらってスマホ側にワンタイムパスワードを表示し、ブラウザ側でそれを入力してもらうという使い方もできると思います。

ぜひ皆さんもいろいろと試してみてくださいね。

ではでは〜!

このエントリーをはてなブックマークに追加       follow us in feedly