💨

Laravel標準の会員登録時のメール認証のセキュリティが意外と高かった

2022/04/22に公開

Laravel標準の会員登録時のメール認証を追っていくと意外と面白かったので記事にしてみました。

通常のフローはこのような感じです。

1. 会員登録 (この時点でauth()->login()は通るのでログイン状態)
2. 手元に認証メールが届く
3. メール本文にあるリンクをクリックすると認証完了!

この3番の「メール本文にあるリンクをクリックする」の部分について深ぼっていきたいのですが、
まずはその前の認証メールの本文内にあるリンクの生成方法についてからみていきたいと思います。

認証メール本文内の認証用URL生成の方法について

Laravelでは、 $user->SendEmailVerificationNotification() この文だけで、認証メールが送信されます。

これで送られるメールの本文はどのようにつくられているのかというと、
vendor/laravel/framework/src/Illuminate/Auth/Notifications/VerifyEmail.php で定義されています。

    /**
     * Build the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return \Illuminate\Notifications\Messages\MailMessage
     */
    public function toMail($notifiable)
    {
        $verificationUrl = $this->verificationUrl($notifiable);

        if (static::$toMailCallback) {
            return call_user_func(static::$toMailCallback, $notifiable, $verificationUrl);
        }

        return $this->buildMailMessage($verificationUrl);
    }

$this->verificationUrl で認証用のURLを生成して、$this->buildMailMessage($verificationUrl) 本文を作成しているようですね。

本文はどうでもいいので、 URLの生成方法を知るために、 verificationUrlメソッドを見ていきます。

    /**
     * Get the verification URL for the given notifiable.
     *
     * @param  mixed  $notifiable
     * @return string
     */
    protected function verificationUrl($notifiable)
    {
        if (static::$createUrlCallback) {
            return call_user_func(static::$createUrlCallback, $notifiable);
        }

        return URL::temporarySignedRoute(
            'verification.verify',
            Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
            [
                'id' => $notifiable->getKey(),
                'hash' => sha1($notifiable->getEmailForVerification()),
            ]
        );
    }

ここでは URL::temporarySignedRouteを使って署名付き期限付きURLを発行しています。
$notifiable->getKey() はユーザーIDで、 $notifiable->getEmailForVerification() はユーザーのメールアドレスです。
IDとメールアドレスのハッシュをうまく使って、認証用URLを生成しているようです。

メールのリンクをクリックした後の処理について

メール確認をする際には、公式ドキュメントにもあるとおり、このルーティングでできます。

use Illuminate\Foundation\Auth\EmailVerificationRequest;

Route::get('/email/verify/{id}/{hash}', function (EmailVerificationRequest $request) {
    $request->fulfill();

    return redirect('/home');
})->middleware(['auth', 'signed'])->name('verification.verify');

まず気になるところは、 functionの中にある EmailVerificationRequest $request です。
Laravel標準のFormRequestという機能を使って、ここでバリデーション処理をしています。
中身を見てみます。( vendor/laravel/framework/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php

    public function authorize()
    {
        if (! hash_equals((string) $this->route('id'),
                          (string) $this->user()->getKey())) {
            return false;
        }

        if (! hash_equals((string) $this->route('hash'),
                          sha1($this->user()->getEmailForVerification()))) {
            return false;
        }

        return true;
    }

authorizeがtrueになればバリデーション通過、falseはバリデーションエラーです。
URL(/email/verify/{id}/{hash})の IDの部分と、 認証ユーザーのIDが一致するかどうか確認しています。

次に、URLのhashの部分と、認証ユーザーのメールアドレスをsha1でハッシュ化したものと一致するかどうか確認しています。

↑つまり、会員登録した時と同じユーザーがリンクを開かないと、メール認証は通過しないわけです。

そして次に、

$request->fulfill();

この部分ですが、これは、 email_verified_atに日時を入れているだけです。これで処理終了です。

## つまり言いたかったこと

例えば、Aさんが会員登録して認証メールが送られてきて、Bさんが認証用リンクをクリックしても認証は成功しない。なりすましができないということですね。
メール認証はログイン状態(auth)が必須ということで、また一つ学びになりました。
よくよく考えてみると当たり前のような、当たり前じゃないような。

というわけで、久しぶりの記事でしたが、そんな感じでさようなら。

Discussion