PHP

GoogleやLINEみたいなSMS二要素認証をTwilioでサクッと実装してみよう

2017/04/07

Younes Rafie

51

Articles in this issue reproduced from SitePoint
Copyright © 2017, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2017, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

GoogleのログインやLINEのアカウント登録時など、おなじみになったSMSによる二要素認証。クラウド電話APIのTwilioを使ってLaravel製アプリに手軽に実装する方法を紹介。

誰もがアプリケーションのセキュリティを心配しますが、真剣に受け止めて行動に移す人はほとんどいません。実際にセキュリティについて考えたとき、最初のステップとして頼りになる手法が二要素認証(2FA)です。

多くのユーザーは支払いやチャット、電子メールなどの重要なサービスに対して一般的で推測しやすいパスワードを使う傾向があるので、2つ目の要素としてテキストメッセージを使うことにも問題はあるものの、ユーザー名とパスワードの組み合わせだけで認証するよりは確実に安全です。

この記事では、Twilio SMSを使って二要素認証に対応したLaravelアプリケーションを構築します。

Twilio and Laravel

今回作るもの

すでに二要素認証の流れを理解していれば簡単です。

  • ユーザーはログインページにアクセス
  • ユーザーは電子メールとパスワードを入力
    Login form
  • 電話番号を使用して確認コードを送信
    2FA verification code SMS
  • ユーザーは受信したコードを入力
    Type verification code
  • コードが正しい場合はログインでき、正しくない場合は、もう一度やり直せる
    Dashboard

最終的なデモアプリケーションはGitHubにアップされています。

インストール

開発環境は、前提としてすでに構築されているとします。もし構築していない場合はHomestead Improvedを参考にすれば、簡単に始められます。

LaravelインストーラーかComposerを使用して、新しいLaravelプロジェクトを作成してください。

laravel new demo

または、次のようにします。

composer create-project --prefer-dist laravel/laravel demo

.envファイルを編集して、データベースの接続情報を追加します。

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=root
DB_PASSWORD=root

Scaffoldの作成

マイグレーションを作成する前に、Laravelには認証フローに役立つscaffoldコマンドがあるので、これを使いましょう。以下を生成します。

  • ログインや登録、パスワードリセットのビューとコントローラ
  • 必要なルーティング

コマンドラインからphp artisan make:authを実行してください。

マイグレーションの作成

usersのマイグレーションクラスを更新し、country_codephoneフィールドを追加します。

// database/migrations/2014_10_12_000000_create_users_table.php

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->string('country_code', 4)->nullable();
            $table->string('phone')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}

すべてのユーザーには、生成したトークン(認証コード)のリストがあります。コマンドでphp artisan make:model Token -mを実行して、モデルとマイグレーションファイルを生成します。テーブルスキーマは以下のようになります。

// database/migrations/2016_12_14_105000_create_tokens_table.php

class CreateTokensTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tokens', function (Blueprint $table) {
            $table->increments('id');
            $table->string('code', 4);
            $table->integer('user_id')->unsigned();
            $table->boolean('used')->default(false);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('tokens');
    }
}

確認コードを4桁に限定しましたが、桁数を増やせば増やすほど推測しにくくなります。あとでこの点については触れます。php artisan migrateを実行してデータベースを作成します。

モデルの更新

モデルはすでにあるので、対応するように更新する必要があります。

// app/User.php

class User extends Authenticatable
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'country_code',
        'phone'
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * User tokens relation
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function tokens()
    {
        return $this->hasMany(Token::class);
    }

    /**
     * Return the country code and phone number concatenated
     *
     * @return string
     */
    public function getPhoneNumber()
    {
        return $this->country_code.$this->phone;
    }
}

特別なことはなにもしていません。ただusers -> tokensの関係を追加し、ユーザーの電話番号を取得するためにヘルパーメソッドgetPhoneNumberを追加しました。

// app/Token.php

class Token extends Model
{
    const EXPIRATION_TIME = 15; // minutes

    protected $fillable = [
        'code',
        'user_id',
        'used'
    ];

    public function __construct(array $attributes = [])
    {
        if (! isset($attributes['code'])) {
            $attributes['code'] = $this->generateCode();
        }

        parent::__construct($attributes);
    }

    /**
     * Generate a six digits code
     *
     * @param int $codeLength
     * @return string
     */
    public function generateCode($codeLength = 4)
    {
        $min = pow(10, $codeLength);
        $max = $min * 10 - 1;
        $code = mt_rand($min, $max);

        return $code;
    }

    /**
     * User tokens relation
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * True if the token is not used nor expired
     *
     * @return bool
     */
    public function isValid()
    {
        return ! $this->isUsed() && ! $this->isExpired();
    }

    /**
     * Is the current token used
     *
     * @return bool
     */
    public function isUsed()
    {
        return $this->used;
    }

    /**
     * Is the current token expired
     *
     * @return bool
     */
    public function isExpired()
    {
        return $this->created_at->diffInMinutes(Carbon::now()) > static::EXPIRATION_TIME;
    }
}

リレーションメソッドを設定し、fillable属性を更新する以外に、以下を追加しました。

  • 生成時にcodeプロパティを設定するコンストラクター
  • コード長のパラーメーターに応じて乱数を生成するgenerateCodeメソッド
  • リンクが定数EXPIRATION_TIMEで設定した有効期限に対して切れているかどうかを確認するためのisExpiredメソッド
  • リンクが有効期限内で使用可能かどうかを確認するためのisValidメソッド

ビューの作成

registerビューファイルは、国コードと電話番号フィールドを含むように変更する必要があります。

// resources/views/auth/register.blade.php

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <div class="panel panel-default">
                <div class="panel-heading">Register</div>
                <div class="panel-body">
                    @include("partials.errors")

                    <form class="form-horizontal" role="form" method="POST" action="{{ url('/register') }}">
                        // ...

                        <div class="form-group">
                            <label for="phone" class="col-md-4 control-label">Phone</label>

                            <div class="col-md-6">

                                <div class="input-group">
                                    <div class="input-group-addon">
                                        <select name="country_code" style="width: 150px;">
                                            <option value="+1">(+1) US</option>
                                            <option value="+212">(+212) Morocco</option>
                                        </select>
                                    </div>
                                    <input id="phone" type="text" class="form-control" name="phone" required>

                                    @if ($errors->has('country_code'))
                                        <span class="help-block">
                                        <strong>{{ $errors->first('country_code') }}</strong>
                                    </span>
                                    @endif
                                    @if ($errors->has('phone'))
                                        <span class="help-block">
                                        <strong>{{ $errors->first('phone') }}</strong>
                                    </span>
                                    @endif
                                </div>
                            </div>
                        </div>

                        // ...
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

次に、ユーザーが認証コードを入力するための新しいビューを作成します。

// resources/views/auth/code.blade.php

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <div class="panel panel-default">
                    <div class="panel-heading">Login</div>
                    <div class="panel-body">
                        @include("partials.errors")

                        <form class="form-horizontal" role="form" method="POST" action="{{ url('/code') }}">
                            {{ csrf_field() }}

                            <div class="form-group">
                                <label for="code" class="col-md-4 control-label">Four digits code</label>

                                <div class="col-md-6">
                                    <input id="code" type="text" class="form-control" name="code" value="{{ old('code') }}" required autofocus>

                                    @if ($errors->has('code'))
                                        <span class="help-block">
                                        <strong>{{ $errors->first('code') }}</strong>
                                    </span>
                                    @endif
                                </div>
                            </div>

                            <div class="form-group">
                                <div class="col-md-8 col-md-offset-4">
                                    <button type="submit" class="btn btn-primary">
                                        Login
                                    </button>
                                </div>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

errors.blade.php内で認証エラーのリストを出力します。

// resources/views/errors.blade.php

@if (count($errors) > 0)
    <div class="alert alert-danger ">
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span></button>
        <ul>
            @foreach ($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif

コントローラの作成

新しいコントローラを作成するのではなく、認証コントローラを再利用します。新しく追加する必要はありません。

RegisterController@registerメソッドは、ユーザーがフォームを投稿するときに呼び出され、ユーザーが作成されたあとにregisteredメソッドを呼び出します。

// app/Http/Controllers/RegisterController.php

class RegisterController extends Controller
{

    // ...
    protected function registered(Request $request, $user)
    {
        $user->country_code = $request->country_code;
        $user->phone = $request->phone;
        $user->save();
    }
}

リクエストのバリデーションを更新し、国コードと電話番号フィールドを作成する必要があります。

// app/Http/Controllers/RegisterController.php

class RegisterController extends Controller
{

    // ...
    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name'         => 'required|max:255',
            'email'        => 'required|email|max:255|unique:users',
            'password'     => 'required|min:6|confirmed',
            'country_code' => 'required',
            'phone'        => 'required'
        ]);
    }
}

次にLoginControllerを更新してloginメソッドをオーバーライドします。

// app/Http/Controllers/LoginController.php

class LoginController extends Controller
{

    // ...
    public function login(Request $request)
    {
        $this->validateLogin($request);

        //retrieveByCredentials
        if ($user = app('auth')->getProvider()->retrieveByCredentials($request->only('email', 'password'))) {
            $token = Token::create([
                'user_id' => $user->id
            ]);

            if ($token->sendCode()) {
                session()->set("token_id", $token->id);
                session()->set("user_id", $user->id);
                session()->set("remember", $request->get('remember'));

                return redirect("code");
            }

            $token->delete();// delete token because it can't be sent
            return redirect('/login')->withErrors([
                "Unable to send verification code"
            ]);
        }

        return redirect()->back()
            ->withInputs()
            ->withErrors([
                $this->username() => Lang::get('auth.failed')
            ]);
    }
}

リクエストをバリデートしたあと、メールとパスワードを使用してユーザーを取得します。ユーザーが存在する場合は、新しいトークンを生成、コードを送信、必要なセッション情報を設定して、コードの入力ページにリダイレクトします。

ちょっと待ってください。Tokenモデルの中にsendCodeメソッドを定義していません。

Twilioの追加

SMS経由でユーザにコードを送信する前に、Twilioを設定する必要があります。Twilioの新しいトライアルアカウントを作成します。

そのあと、Twilioのコンソールページに移動し、アカウントIDと認証トークンをコピーします。最後に、SMSを送信するための新しい電話番号をコンソールの電話番号ページで作成します。

TWILIO_SID=XXXXXXXXXXX
TWILIO_TOKEN=XXXXXXXXXXXX
TWILIO_NUMBER=+XXXXXXXXXX

Twilioには公式のPHPのパッケージがあります。

composer require twilio/sdk

Twilioパッケージを使うには、新しいプロバイダーを作成し、コンテナーにバインドします。

pgp artisan make:provider TwilioProvider
// app/Providers/TwilioProvider.php

use Illuminate\Support\ServiceProvider;
use Twilio\Rest\Client;

class TwilioProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {}

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind('twilio', function() {
            return new Client(env('TWILIO_SID'), env('TWILIO_TOKEN'));
        });
    }
}

ここでsendCodeメソッドに戻ります。

// app/Token.php

class Token extends Model
{
    //...
    public function sendCode()
    {
        if (! $this->user) {
            throw new \Exception("No user attached to this token.");
        }

        if (! $this->code) {
            $this->code = $this->generateCode();
        }

        try {
            app('twilio')->messages->create($this->user->getPhoneNumber(),
                ['from' => env('TWILIO_NUMBER'), 'body' => "Your verification code is {$this->code}"]);
        } catch (\Exception $ex) {
            return false; //enable to send SMS
        }

        return true;
    }
}

トークンがユーザーに紐付いていない場合、関数は例外をスローします。そうでなければ、SMSを送信します。

これでアプリケーションの準備は完了です。新しいユーザーを登録、ログインしてテストしてみます。以下は簡単なデモです。

Testing application

二要素認証の参考リンク

最後に

この記事では、Twilioを使ってLaravelアプリケーションに二要素認証を追加する方法を紹介しました。今回のデモに二要素認証のオンオフ機能を追加したら、さらに有用なものになります。またSMSの代わりに電話をかけたい場合もあるかもしれませんね!

※本記事はChristopher Thomasが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:How to Secure Laravel Apps with 2FA via SMS

[翻訳:萩原伸悟/編集:Livit

Copyright © 2017, Younes Rafie All Rights Reserved.

Younes Rafie

Younes Rafie

モロッコ出身のフリーランスのWeb開発者、技術系ライター・ブロガーです。JAVA、J2EE、JavaScriptでの共同作業経験があり、専門はPHP言語です。

Loading...