【Laravel】電話の音声で自動ログインする

さてさて、前回の記事Twilioでブラウザから電話を掛ける・受けるという記事ではブラウザから電話の発信&着信をしてみました。

そして、この機能を開発したあとで、Twilioを使えばより面白い機能を開発することができる(実際は、最低金額を課金したので貧乏性な私はできるだけ減価償却したいという気持ちも強かった😂)ので、今回は前回に引き続きTwilioの記事をお届けしたいと思います。

そして、その内容はというと・・・

通話を使った自動ログインです

です。

これは、例えばログインするメールアドレスやパスワードを忘れてしまったときに登録済みの電話番号から通話をすることで自動ログインすることができるという機能です。(もしくは強力な認証にするために、通話のみでログインする形でもいいかもしれませんし、2段階認証として使ってもいいでしょう)

※ ちなみに今回の機能を使うとこんなカンジの通話になります。
↓↓↓

ぜひ皆さんのお役に立てると嬉しいです😊✨

開発環境: Laravel 5.8

やりたいこと

通話を使った2段階認証は次の流れで実装します。

  1. ブラウザからアクセス
  2. 登録されている自分の電話番号を送信
  3. 4桁の数字をランダムに作成して表示
  4. Twilioで購入した電話番号にスマホなどから発信
  5. 4桁の数字を入力して本人確認
  6. ブラウザ側で自動ログインしてリダイレクト

では実際にやってみましょう!

前提として

Laravelにログイン機能がインストールされていることが前提となっています。もしインストールがまだの方は以下を参考してください。

Twilioに登録する

もちろん今回の機能を実装するにはTwilioに登録しておき、電話番号を購入しておかないといけません。やり方については前回記事、Twilioでブラウザから電話を掛ける・受けるで紹介していますので、先にそちらで作業を行ってください。

Twilioのパッケージをインストールする

TwilioPHPのヘルパーライブラリを用意してくれています。
通話時のプログラム実行で使いますので、以下のコマンドでインストールしておきましょう。

composer require twilio/sdk

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

今回の機能を実現するために、通常のusersテーブルに以下4つのフィールドを追加します。

  • phone_number ・・・ 電話番号
  • tfa_phone_token ・・・ 2段階認証トークン(電話用)
  • tfa_browser_token ・・・ 2段階認証トークン(ブラウザ用)
  • tfa_succeeded ・・・ 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\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddTwoFactorAuthFieldsToUsers extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            Schema::table('users', function (Blueprint $table) {
                $table->string('phone_number')
                    ->nullable()
                    ->after('remember_token')
                    ->comment('電話番号');
                $table->string('tfa_phone_token')
                    ->nullable()
                    ->after('phone_number')
                    ->comment('2段階認証トークン(電話用)');
                $table->string('tfa_browser_token')
                    ->nullable()
                    ->after('tfa_phone_token')
                    ->comment('2段階認証トークン(ブラウザ用)');
                $table->boolean('tfa_succeeded')
                    ->default(false)
                    ->after('tfa_browser_token')
                    ->comment('2段階認証の成功');
            });
        });
    }

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

変更が完了したら、以下のコマンドでマイグレーションを実行してください。

php artisan migrate

実行が完了するとテーブルは以下のようになります。

ルートをつくる

続いてルートです。
routes/web.phpに以下のコードを追加してください。

// ブラウザ側
Route::get('twilio/two_factor_auth/form', 'TwilioController@form');
Route::post('/ajax/twilio/set_token', 'TwilioController@setToken');
Route::post('/ajax/twilio/auth_check', 'TwilioController@checkAuth');

// 通話側(Twilioがアクセスする部分)
Route::post('twilio/phone_input', 'TwilioController@phoneInput')->name('twilio.phone_input');
Route::post('twilio/phone_submit/{user}', 'TwilioController@phoneSubmit')->name('twilio.phone_submit');

なお、TwilioからのアクセスはCSRFトークンを含んでいませんので、app/Http/Middleware/VerifyCsrfToken.php$exceptに通話側のURLを登録してミドルウェアを解除しておきましょう。

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    /**
     * Indicates whether the XSRF-TOKEN cookie should be set on the response.
     *
     * @var bool
     */
    protected $addHttpCookie = true;

    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array
     */
    protected $except = [
        'twilio/phone_input',
        'twilio/phone_submit/*'
    ];
}

コントローラーをつくる

php artisan make:controller TwilioController

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

<?php

namespace App\Http\Controllers;

use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Twilio\TwiML\VoiceResponse;

class TwilioController extends Controller
{
    private $language = ['language' => 'ja-jp'];

    // ブラウザ側

    public function form() {

        return view('twilio.two_factor_auth')
            ->with([
                'twilio_phone_number' =>  'xxx-xxxx-xxxx', // Twilioで購入した電話番号
                'browser_token' => Str::random()
            ]);

    }

    public function setToken(Request $request) {

        $result = false;
        $user = null;
        $phone_token = '';

        if($request->filled('phone_number')) {

            $user = \App\User::where('phone_number', $request->phone_number)->first();

            if(!is_null($user)) {

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

                    $phone_token .= rand(0, 9);

                }

                $user->tfa_phone_token = $phone_token;
                $user->tfa_browser_token = $request->browser_token;
                $result = $user->save();

            }

        }

        return [
            'result' => $result,
            'phone_token' => $phone_token
        ];

    }

    public function checkAuth(Request $request) {

        $result = false;

        if($request->filled('phone_number')) {

            $user = \App\User::where('phone_number', $request->phone_number)
                ->where('tfa_phone_token', $request->phone_token)
                ->where('tfa_browser_token', $request->browser_token)
                ->first();

            if(!is_null($user) && $user->tfa_succeeded) {

                \Auth::login($user);
                $user->tfa_phone_token = null;
                $user->tfa_browser_token = null;
                $user->tfa_succeeded = false;
                $result = $user->save();

            }

        }

        return ['result' => $result];

    }

    // 通話側(Twilioがアクセスする部分)
    public function phoneInput(Request $request) {

        $incoming_phone_number = preg_replace('|^\+81|', '0', $request->From);  // 世界的な番号から日本番号へ変換
        $user = \App\User::where('phone_number', $incoming_phone_number)->first();
        $response = new VoiceResponse();

        if(!is_null($user)) {

            $gather = $response->gather([
                'action' => route('twilio.phone_submit', $user->id),   // phoneSubmit()へデータ送信
                'method' => 'POST',
                'numDigits' => '4'  // 4桁入力する
            ]);
            $gather->say('現在ブラウザに表示されている4桁の数字をプッシュ入力してください。', $this->language);

        } else {

            $response->say('該当する電話番号が見つかりませんでした。登録された電話番号から発信してください。', $this->language);
            $response->leave();

        }

        return $this->twimlResponse($response);

    }

    public function phoneSubmit(User $user, Request $request) {

        $result = false;
        $response = new VoiceResponse();

        if($request->filled('Digits')) {

            $digits = $request->Digits; // 入力された番号

            if(!empty($digits) && $user->tfa_phone_token === $digits) {

                $user->tfa_succeeded = true;
                $result = $user->save();

            }

        }

        if($result) {

            $response->say('認証に成功しました。ブラウザ側で自動的にログインされます。', $this->language);

        } else {

            $response->say('入力された番号が正しくありません。', $this->language);

        }

        $response->leave();
        return $this->twimlResponse($response);

    }

    private function twimlResponse($twiml_response) {

        return response($twiml_response, 200)->header('Content-Type', 'text/xml');

    }

}

少しコードが長いので、ブラウザ側と通話側で分けて説明をします。

ブラウザ側

form()

ブラウザで直接アクセスするメソッドです。
このページから電話番号を送信して通話時の認証用トークン(tfa_phone_token, tfa_browser_token)を保存することになります。

なお、twilio_phone_numberの部分はTwilioで購入した番号へ変更してください。

setToken()

form()ページからAjaxで電話番号を受け取るメソッドです。
ここでは、通話時に入力してもらう4桁のランダムな数字を作ってtfa_phone_tokenに保存します。

また、同時にform()で作成されたトークンをtfa_browser_tokenとして保存していますが、これはなりすましログインを防ぐために「電話番号が送信されたブラウザだけ」が自動ログインできるようにしています。

※ 正直なところ、複雑になってしまうのでtfa_browser_tokenをつけるかどうか悩みましたが私の記事のせいで不正アクセス発生😫なんてことになるのは嫌なのであえてつけました。

checkAuth()

こちらもAjaxでアクセスされるメソッドですが、これは1秒間に1回何度もアクセスされることになります。

そして、通話を使ったログインが成功した段階でログインをし、form()ページは自動でリダイレクトされることになります。

通話側

phoneInput()

Twilioに発信すると一番先にアクセスされるメソッドです。

この中では発信者の電話番号を取得し、「その電話番号を登録しているユーザーが存在しているか」をチェックします。そして、もし存在してるなら4桁の数字の入力を求めるという内容になっています。

phoneSubmit()

phoneInput()で入力された4桁の数字が送信されてくるメソッドです。

この数字と、すでに登録されているtfa_phone_tokenが同じ値の場合は認証が成功したということでtfa_succeededtrueで保存してます。

tfa_succeededは、checkAuth()メソッドの中で利用されることになります。

ビューをつくる

ブラウザからアクセスするフォーム用のビューを作成します。resources/views/twilio/two_factor_auth.blade.phpというファイルをつくって中身を以下のようにしてください。

<html>
<head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container p-5" id="app">
        <div class="row" v-if="!hasPhoneToken">
            <div class="col-md-6">
                <div class="card bg-light">
                    <div class="card-body">
                        <div class="form-group">
                            <label>登録されている電話番号</label>
                            <input class="form-control" type="text" v-model="phoneNumber">
                        </div>
                        <button class="btn btn-primary" type="button" @click="onSubmit">送信する</button>
                    </div>
                </div>
            </div>
        </div>
        <div class="row" v-if="hasPhoneToken">
            <div class="col-md-6">
                <div class="card bg-light">
                    <div class="card-body">
                        <div class="form-group">
                            <strong>「{{ $twilio_phone_number }}」</strong>に発信して以下の数字を入力してください。
                            <h1 class="text-center mt-4" v-text="phoneToken"></h1>
                        </div>
                    </div>
                </div>
            </div>
        </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: {
                phoneNumber: '',
                phoneToken: '',
                browserToken: '{{ $browser_token }}'
            },
            methods: {
                onSubmit() {

                    const url = '/ajax/twilio/set_token';
                    const params = {
                        phone_number: this.phoneNumber,
                        browser_token: this.browserToken
                    };
                    axios.post(url, params)
                        .then(response => {

                            if(response.data.result) {

                                this.phoneToken = response.data.phone_token;

                            } else {

                                alert('該当する電話番号が存在しません。');

                            }

                        });

                }
            },
            computed: {
                hasPhoneToken() {

                    return (this.phoneToken);

                }
            },
            mounted() {

                // 1秒ごとに電話からの認証が完了したかをチェック
                const timer = setInterval(() => {

                    if(this.hasPhoneToken) {

                        const url = '/ajax/twilio/auth_check';
                        const params = {
                            phone_number: this.phoneNumber,
                            phone_token: this.phoneToken,
                            browser_token: this.browserToken
                        };
                        axios.post(url, params)
                            .then(response => {

                                if(response.data.result) {

                                    clearInterval(timer);
                                    console.log('ログイン成功');
                                    // ここでリダイレクト
                                    location.href = '/home';

                                }

                            });

                    }

                }, 1000);

            }
        });

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

こちらもコードが少し長いですがやっていることは主に以下の2つだけです。

  • 電話番号をAjaxで送信する
  • 通話側の認証が完了しているかを1秒毎にAjaxで確認する

実際にブラウザで表示すると次のようになります。

(電話番号を入力するフォーム)

(通話をうながす表示)

Twilioでウェブフックを設定する

では、ここまでで作成したURLにTwilioがアクセスできるようにウェブフックを設定します。

ウェブフックは購入した電話番号の詳細ページの「Voice」から設定できます。

入力したら「Save」ボタンをクリックして保存してください。

これで作業は終了です。
お疲れ様でした😊✨

テストしてみる

では実際に今回の機能を試してみましょう。
テストとしてusersテーブルに自分の携帯番号を保存し、ブラウザからその番号を送信(&4桁の数字を表示)した状態で通話を開始しています。

どうでしょう。

イントネーションが少し微妙なところもありますが、自分がつくった文章をここまで会話に近い音声にしてくれています。

また、ブラウザ側では認証が完了した直後に自動的にリダイレクトされることも確認しました。

成功です😊✨

ちなみに

そもそもの話になるのですが、通話を使ったログインは事前に電話番号が登録されていることが大前提です。

また、電話番号をusersテーブルに登録する際もショートメッセージなどでパスワードを送信し、その数字を入力することで認証するなどの本人確認も必要になってくるかと思います。

また、今回作成した電話番号のトークンも本来は有効期限をつけるべきですが、複雑になりすぎるのでこの部分は割愛しています。

おわりに

ということで今回はTwilioを使って通話での認証機能を実装してみました。

最近では、LINEなどのメッセージアプリを使っているのでプライベートでの通話は減ってきた感はありますが、ビジネスの世界ではまだまだ電話が使われていますので、Twilioを使うことでもっともっと面白い機能を実現することができると思います。

また、ちょっとコードが古い(古いパッケージが使われている)のでそのまま使うことはできないようですが、Twilioを使った以下のような実例をKDDIが公開してくれています。

ぜひこちらも参考にしてみてはいかがでしょうか。

ではでは〜!

この記事が役立ちましたらシェアお願いします😊✨