メール認証(アクティベーション)機能を自前で実装【Laravel】

この記事は、[認証認可技術 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/identity) の23日目の記事です。



# 1. はじめに

Laravelって便利ですよね。

Laravel5.7からは、`make:auth`コマンドや`MustVerifyEmail`インターフェースを使えばすぐにメール認証機能の実装もできるようになりました。(Laravel6.0からは`make:auth`廃止されましたが)


ただ便利な反面、途中から機能追加したり、変更頻度が多いことを見越して自前で実装して柔軟に対応できるようにしておきたいことがあります。
こういったことがあって自前で実装したのでその時の備忘録です。
(APIサーバをつくるときの話で、LaravelはフルスタックフレームワークなのでAPIサーバのみの利用はあまり無かった)

少し前の実装なのでうる覚えな部分はありますがご容赦ください。

# 2. やりたいこと

タイトル通り、メール認証機能の実装です。

私が思っているメール認証機能の定義と流れは以下の通りです。

## 2.1. メール認証機能の定義

Webサービスでよくある新規登録時にほぼ間違いなくするメールアドレスの有効化のことです。
(`メール認証==メール有効化==メールアクティベーション`だと思っています。。。色んな言い方をしてすみません。)

この機能がないと偽りのメールアドレスを使って登録することができてしまうのでよく実装されています。(この他にもSMS認証とか)
要するにWebサービスを開発するときの必須機能の一つです。

## 2.2. メール認証機能の流れ

1. ユーザがサーバにメールアドレスを入力して送信
2. サーバがユーザに入力されたメールアドレスに認証メールを送信
3. ユーザが認証メール内のURLにアクセス
4. サーバがURLの正誤判定
5. 正しい場合、サーバがデータベースにメールアドレスを登録

一般的な流れはこんな感じでしょうか。
誰でも一度はユーザの立場でやったことがある流れだと思います。

# 3. 実装方針
## 認証コードの作成・確認

認証コードの作成は、メールアドレスとサーバで定義しておいた秘密鍵を用いてハッシュ化をして行います。

確認は、`ユーザがアクセスしたURL内の認証コード==サーバ側に仮保存している認証コード`で行います。

## 認証コードとメールアドレスの紐付け 

考える必要があるのは紐付け方法です。

様々な紐付け方法がありますが、今回はデータベースにメール認証用のテーブルを作って、メールアドレスと認証コードをセットで保存するという方法をとります。
メールアドレスの認証が確認できた時点で、このテーブルのレコードは消していきます。

### おまけ: 様々な紐付け方法

- アクセス先のページでメールアドレスを再入力
    - ユーザが手間(=UX悪い)なので却下
- URLにクエリとしてメールアドレスを付与
    - ださいので却下、セキュリティ的にも心配(？)
- メールアドレスのセッション保存
    - 他端末で認証できないので却下
- 認証ファイルの作成
    - 個人的に実装が手間だったので迷った末に却下
    - データベース使わなくて済むが小規模なサービスの場合だと誤差

# 4. 開発環境

- PHP7.3
- Laravel6.0(5.8でも動作確認済)
- MySQL8.0


# 5. 実装
## 5.1. テーブル作成(マイグレーション&モデル)
最初に、ユーザ用のテーブルとは別に、メール認証用のテーブルをつくっておきます。
MigrationとModelをつくります。

```shell
$ php artisan make:model MailVerification -m
```

### Migration

`mail_authentication`は認証コードを格納するカラムで、
`mail`はメールアドレスを格納するカラムです。

```PHP:database/migrations/2019_12_23_000000_create_mail_verifications_table.php
<?php

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

class CreateMailVerificationsTable extends Migration
{
    public function up()
    {
        Schema::create('mail_verifications', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('mail_authentication');
            $table->string('mail')->unique();
            $table->timestamps();
        });
    }
// 略
}

```

### Model
```PHP:app/Models/MailVerification.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class MailVerification extends Model
{
    protected $table = 'mail_verifications';

    protected $guarded = array('id');
  
    public $timestamps = true;

    protected $fillable =[
        'mail_authentication'
        , 'mail'
    ];
}

```

## 5.2. コントローラ作成

次に、コントローラをつくります。

### Controller

`store`メソッドで認証コードとメールアドレスを仮保存しておいて、ユーザに認証メールを送ります。
認証メールのURLにアクセスしたら`verify`メソッドで確認して、仮保存しておいたレコードを削除し、認証完了メールを送ります。

`createActivationCode`メソッドで、認証コードを作成しています。
`ACTIVATE_SALT`が秘密鍵です。

```PHP:app/Http/Controllers/Api/MailVerificationsController.php
<?php

namespace App\Http\Controllers\Api;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use App\Models\MailVerification;
use App\Models\User;
use App\Mail\MailVerification as MailVerificationMail;
use App\Mail\MailVerificationConfimComplete as MailVerificationConfimCompleteMail;
use App\Http\Controllers\Controller;

define('ACTIVATE_SALT', '[秘密鍵]');

class MailVerificationsController extends Controller
{
    public function store($mail)
    {
        $mail_verification = new MailVerification();

        $mail_verification->mail_authentication = self::createActivationCode($mail);
        $mail_verification->mail = $mail;

        $mail_verification->save();

        self::sendMailVerification($mail_verification);
    }

    public function verify($active_code)
    {
        $mail_verification = self::getMailInfoFromActiveCode($active_code);
        $mail = $mail_verification[0]->mail;

        $user = new User();

        $user->isVerified($mail);

        self::destroy($mail_verification[0]->id);

        self::sendMailVerificationComplete($mail);
    }

    public function destroy($id)
    {
        $mail_verification = MailVerification::find($id);
        $mail_verification->delete();
    }

    private function createActivationCode($mail)
    {
        return hash_hmac('sha256', $mail, ACTIVATE_SALT);
    }

    private function sendMailVerification($mail_verification)
    {
        Mail::to($mail_verification->mail)
            ->send(new MailVerificationMail($mail_verification->mail_authentication));
    }

    private function sendMailVerificationComplete($mail)
    {
        Mail::to($mail)
            ->send(new MailVerificationConfimCompleteMail());
    }

    private function getMailInfoFromActiveCode($active_code)
    {
        return MailVerification::where('mail_authentication', $active_code)->get(); 
    }
}
```

念のため、Userコントローラも。
`store`メソッドでユーザ登録処理をしています。
`app()->make()`で別コントローラを呼ぶという力技・・・。

```PHP:app/Http/Controllers/Api/UsersController.php
<?php

namespace App\Http\Controllers\Api;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
use App\Http\Controllers\Controller;

class UsersController extends Controller
{
    public function store(Request $request)
    {
        $user = new User();

        $user->name = $request->input('name');
        $user->mail = $request->input('mail');
        $user->password = bcrypt($request->input('password'));

        $user->save();

        self::callMailVerificationStore($user);

        return response([], 201);
    }

    private function callMailVerificationStore($user)
    {
        $mail_varification = app()->make('App\Http\Controllers\Api\MailVerificationsController');
        $mail_varification->store($user->mail);
    }
}

```

## 5.3. ルーティング記述
この当たりで関係がある部分のルーティングを書いておきます。

```PHP:routes/api.php
<?php

use Illuminate\Http\Request;

Route::group(["middleware" => "api"], function () {
        Route::post('/user', 'Api\UsersController@store');
        Route::get('/verify/{active_code}', 'Api\MailVerificationsController@verify');
});

```

# 5.4. メール

必要ないかもしれませんがメモ程度に。

## Mail

```PHP:app/Mail/MailVerification.php
<?php

namespace App\Mail;

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

define('MAIL_VERIFICATION_SUBJECT', '[メールタイトル]');

class MailVerification extends Mailable
{
    use Queueable, SerializesModels;

    protected  $mail_verification;

    public function __construct($_mail_verification)
    {
        $this->mail_verification = $_mail_verification;
    }

    public function build()
    {
        $auth_url = config('const.BASE_URL') . '/verify/' . $this->mail_verification;

        return $this->from(config('mail.from.address'))
                    ->subject(MAIL_VERIFICATION_SUBJECT)
                    ->view('mails.verification', compact('auth_url'));
    }
}

```

簡単にですがBladeの方も。

```PHP:resources/views/mails/verification.blade.php
@extends('layouts.app')
 
@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">メールアドレス認証</div>
 
                <div class="card-body"> 
                    <a href='{{$auth_url}}'>こちらのリンク</a>をクリックして、メールを認証してください。
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
```



# 6. おわりに

以上、Laravelでメール認証機能を自前で実装してみた備忘録でした。

ご閲覧ありがとうございました！

質問やアドバイス等ありましたらコメントしてください。