laravelでメールアドレス認証の仕組みを作ってみた

#0. ことのなりゆき
laravelの勉強がてらとあるアプリを個人開発中。
その中で、ユーザ登録をするときにただユーザ名やパスワードを入力してハイ終わりではなくメールアドレスを入力させてそのメールアドレスの認証をするみたいなちょっと凝ったことをしたくなったのでやってみました。機能的にはいろんなサイトでよく見る当たり前のものですがメモも兼ねて記事にまとめようと思います。

#1. 概要
ざっくりしたフローは以下の通り。
![メアド認証シーケンス.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551999/86c1c82f-03f8-1200-597c-6abc0638d852.png)

書き方はだいぶ適当だけど処理の流れだけでも伝わるかな・・・

#2. フロー詳細
1.で示したフローを、もう少し具体的にしていきます。ここで決めた仕様をもとに実装します。数字はシーケンス図と対応。

<dl>
<dt>1. ユーザ名・メールアドレス・パスワードを入力</dt>
<dd>まずはユーザからの入力がないと始まりません。フロントでユーザの入力を受け取ります。</dd>
<br>
<dt>2. トークンを発行</dt>
<dd>一意の文字列を発行します。また、有効期限も設定したいので作成時刻から30分後の時間を求めます。</dd>
<br>
<dt>3. ユーザ情報・トークンデータを保存</dt>
<dd>ユーザから受け取ったユーザデータとサーバで作成したトークンデータ
をDBへ保存します。トークンは最初の認証が終われば不要になりますので、それぞれ別のテーブルを用意します。ユーザテーブルにはメールアドレスが認証されているかのフラグとなるカラムを、トークンテーブルにはトークンが認証されているかのフラグとなるカラムを用意しそれぞれfalseを入れて保存します。</dd>
<br>
<dt>4. トークンを含んだURLが書かれたメール送信</dt>
<dd>1.で入力されたメールアドレス宛にメールを送信します。メールには認証ページのURLにトークンをクエリパラメータとして持たせたものを記載しておき、このURLをクリックするよう促すメッセージも書いておきます。</dd>
<br>
<dt>5. URLクリック</dt>
<dd>これは完全にユーザの仕事なのでアプリ側でやることはなし。</dd>
<br>
<dt>6. ユーザから送信されたトークンを検索</dt>
<dd>5. でユーザがトークンを持って認証ページにアクセスしてきたらそのトークンでDBを検索します。</dd>
<br>
<dt>7. トークン情報</dt>
<dd>検索結果がDBから返ってきます。ここには、そのトークンがどのメールアドレスに対して発行されたものかや有効期限も含まれています。</dd>
<br>
<dt>8. トークンチェック</dt>
<dd>7. で返ってきたデータからメールアドレスの認証を行います。今回は以下の項目についてチェックしていきます。<br>
・トークン（文字列）が正しいか（DBに存在するか）<br>
・有効期限が切れていないか<br>
・すでに認証されたトークンでないか<br>
</dd>
<br>
<dt>9. 認証処理</dt>
<dd>8. のチェックがOKだった場合、ユーザテーブルのメールアドレス認証フラグおよびトークンテーブルのトークン認証フラグをtrueにします。</dd>
<br>
<dt>10. 各ページへリダイレクト</dt>
<dd>8.のチェックがOKだった場合、そのユーザはログイン状態にしてユーザトップページ（Twitterでいうプロフィール）へ、チェックがNGだった場合はトップページにそれぞれリダイレクトさせます。また、認証NGだった場合に関しては何がダメだったのかをユーザに知らせるためのメッセージも表示します。</dd>
</dl>

・・・結構ボリュームあるな

#3. 実装
さて、いよいよ実装します。コードは主要な部分を抜粋&多少アレンジして載せていきます。

### 1の処理(処理してないけど)
```html:index.blade.php
<form action="{{route('user.create')}}" method="POST">
    @csrf
    <label class="row form-label">ユーザ名</label><input class="row form-input" type="text" name="name">
    <label class="row form-label">メールアドレス</label><input class="row form-input" type="text" name="email">
    <label class="row form-label">パスワード</label><input class="row form-input" type="password" name="password">
    <div class="row">
        <input class="form-button" type="submit" value="登録">
    </div>
</form>
```


### 2~4の処理

``` php:UserController.php
    public function create(Request $request)
    {
        //3. ユーザデータを保存
        $userService = new UserService();
        $userService->create($request);

        //2. トークンを発行
        $tokenService = new TokenService();
        $tokenService->create($request);

        //4. メールを送信
        $email = $request->email;
        //メールに記載する認証用URlを組み立てている(認証用ページURL+トークン)。
        $url = request()->getSchemeAndHttpHost(). "/user/register?token=". $tokenService->getToken();

        Mail::to($email)
                ->send(new AuthMail($url));

        //メール送信完了画面へリダイレクト
        return redirect('/join')->with('email',$email);
    }

```

```php:TokenService.php
    public function create(Request $request)
    {
        $email = $request->email;
        $now = new DateTime();
        $now->format("Y-m-d H:i:s");
        //有効期限を計算(30分とした)
        $expire_at = $now->modify('+30 minutes');

        $token = new Token();
        //トークンを生成
        $token = uniqid('', true);
        //3. トークンをDBに保存
        $token->create([
            'token' => $token,
            'email' => $email,
            'expire_at' => $expire_at
        ]);
    }
```
トークン生成には、phpの[uniqid](https://www.php.net/manual/ja/function.uniqid.php)を使います。変に自前で文字列をランダムに生成するメソッド実装するよりもphp様の用意してくださった便利なメソッドを使いましょう。

メール送信用のクラス。laravelのMailaleクラスを継承しています。

```php:AuthMail.php
<?php

namespace App\Mail;

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

class AuthMail extends Mailable
{
    use Queueable, SerializesModels;

    protected $url;

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

    //メール送信で使うビュー、タイトル、ビューに渡す認証用URLを設定
    public function build()
    {
        return $this->view('mail.tmpRegist')
                    ->subject('【App】仮登録が完了しました')
                    ->with(['url'=>$this->url]);
    }
}

```

メール用のビューも一応。

```html:tmpRegist.blade.php
仮登録が完了しました。以下のURLへアクセスして登録を完了させてください。
<a href="{{$url}}">{{$url}}</a>

```
AuthMailのbuild()の中のwithで渡したurlがこのビュー内の$urlになります。


### 6~10の処理

```php:UserController.php
        $token = $request->token;

        //6~8. トークン検索からチェックまでを行う
        $authResult = $tokenService->matchToken($token);

        if( $authResult == "OK"){
            //9. 認証処理(ユーザテーブルのメールアドレス認証フラグ立てる)
            $userService->changeEmailFlag($email);

            $email = $tokenService->getEmailByToken($token);
            $id = $userService->getIdByEmail($email);

            //10. ログイン状態にしてユーザトップページへリダイレクト
            $request->session()->put('logind', 'true');
            $request->session()->put('id', $id);
            return redirect('/user/'.$id);
        }else if($authResult == "ALREADY"){
            //10. エラーメッセージとともにトップページへリダイレクト
            return redirect('/')->with('message', 'このメールアドレスはすでに認証されています。');
        }else if($authResult == "WRONG"){
            //10. エラーメッセージとともにトップページへリダイレクト
            return redirect('/')->with('message', 'メールアドレス認証に失敗しました。URLを確認してもう一度やり直してください。');
        }else if($authResult == "EXPIRE"){
            //10. エラーメッセージとともにトップページへリダイレクト
            return redirect('/')->with('message', '認証URLの有効期限が切れています。最初からもう一度やり直してください。');
        }else{
            //一応
            return redirect('/');
        }
```

```php:TokenService.php
    public function matchToken($token)
    {
        $now = new DateTime();

        //6. ユーザから送信されたトークンを検索
        $data = Token::where([
            'token' => $token
        ])->first();

        //8. トークンチェック
        if(is_null($data)){
            //DBから値が返ってこないのでトークンが間違っている、チェックNG
            return "WRONG";
        }else if($data->auth_flag){
            //検索して見つかったトークンデータの認証フラグが既に立っている(=認証済み)、チェックNG
            return "ALREADY";
        }

        $expire_date = new DateTime($data->expire_at);

        //9. 認証処理(有効なトークンだった場合はフラグを認証済み(true)に更新)
        if($now < $expire_date){
            $data->auth_flag = true;
            $data->update();
            return "OK";
        }else{
            //有効期限が切れている、チェックNG
            //有効期限の切れたトークンデータ、ユーザデータはもう二度と認証できないので削除
            $email = $data->email;
            Token::where([
                'token' => $token
            ])->delete();
            $userService = new UserService();
            $userService->deleteByEmail($email);
            return "EXPIRE";
        }

    }
```

#4. 動かす
せっかく作ったので動かしてみます。

まずはユーザ情報入力。(スタイルあたってるのは無視してください)
![1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551999/2d6e3ffc-a4ba-10a0-b545-2a62a940b519.jpeg)
ユーザ名、メールアドレス、パスワードを入力し登録ボタンをクリック。
すると・・・
![2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551999/5330d954-f4eb-1c3e-2ae3-00e584395af0.jpeg)
ちゃんとメール来てます。URLも認証用ページのURLにクエリパラメータとしてトークンがくっついたものになってますね。
URLをクリックすると・・・
![3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551999/67a701c3-9203-8800-d353-2c55223299f9.jpeg)
認証が通り、ユーザトップページにリダイレクトされました！！（開発途中なのでユーザトップページだとわかりにくくてすみません笑）

ちなみに、一回ログアウトしてさっきのメールのURLをもう一回クリックすると・・・
![4.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551999/c1b8256b-7878-2818-d223-eab53b20dc0b.jpeg)
エラーメッセージとともにトップページにリダイレクトされました。今回の場合は一回認証されたトークンをもう一度認証しようとしたためこのメッセージになりました。
![5.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551999/5263c9e3-1b47-bb2d-cec8-b6e6a620815d.jpeg)
DBの中身(トークンテーブル)を見てみるとちゃんとauth_flagが1になっており認証済みになっていることがわかります。

#5. まとめ
ただユーザ情報をDBに登録して終わりではなくメールアドレス認証というちょっと複雑なフローを取り入れることで設計・実装ともにいい勉強になりました。ちょうど仕事のほうでも来週から認証周りの追加開発案件にアサインされることになっており、いい予行演習になったかなと思っております（全くレベル違うけど）。ただ、コード的にはいろいろ直さなきゃならないとことかセキュリティ的にほんとにこれでいいのかとかいろいろ考えなきゃいけないことはあるのでこのアプリをリリースするまでに（できるのか・・・？）きれいにしておきたいと思います。
それでは、長々と読んでいただきありがとうございました！