PHP
laravel
コードリーディング
laravel5
新人プログラマ応援

Laravelコードリーディング - Laravel5.7のEmail Verificationを読む

本項のテーマ & 前置き

Laravel5.7のコードを読んでいきます。
5.6 → 5.7でいくつかの機能が追加されました。そのうちの一つがEmail Verificationです。
早い話がユーザー登録時のメール確認ですね。以下の点をコード上で確認してみたいと思います。

  • ユーザー登録→確認メール送信
  • メール記載のURLをクリック→登録完了
  • メール確認済みかどうかのチェック

この機能の使い方(コーディング方法など)についは以下の記事がわかりやすいかと思いますのでそちらを参考にしてください(無許可)

最新Laravel 5.7の便利な機能「正しいメールアドレスのみ登録させる機能:Email Verification」

Let's コードリーディング!!

確認メールを送るまで

まずは登録時の処理がどう変わっているのか見ていきましょう。
登録処理はIlluminate\Foundation\AuthRegistersUsersトレイトで実装されています。

vendor/laravel/framework/src/Illuminate/Foundation/Auth/RegistersUsers.php
public function register(Request $request)
{
    $this->validator($request->all())->validate();

    event(new Registered($user = $this->create($request->all())));

    $this->guard()->login($user);

    return $this->registered($request, $user)
                    ?: redirect($this->redirectPath());
}

特に変わった様子はありません。ここで確認メールを送ってるかと思ったのですが違うみたいです。
Registeredイベントを使ってメール送信をしているのかもしれません。イベントサービスプロパイダーを覗いてみましょう。

app/Providers/EventServiceProvider.php
protected $listen = [
    Registered::class => [
        SendEmailVerificationNotification::class,
    ],
];

RegisteredイベントにSendEmailVerificationNotificationハンドラーを登録していました。では、SendEmailVerificationNotificationクラスを見てみます。

vendor/laravel/framework/src/Illuminate/Auth/Listeners/SendEmailVerificationNotification.php
public function handle(Registered $event)
{
    if ($event->user instanceof MustVerifyEmail && ! $event->user->hasVerifiedEmail()) {
        $event->user->sendEmailVerificationNotification();
    }
}

$event->userのuserはAuthRegistersUsersトレイトでRegisteredイベントのコンストラクタの引数として渡していたUserクラスのインスタンスです。つまり、UserクラスのsendEmailVerificationNotificationメソッドを呼び出しています。Userクラスを読み進めて見ましょう。

App\Userクラスは親クラスはIlluminate\Foundation\Auth\Userクラスです。こちらを見てみます。

vendor/laravel/framework/src/Illuminate/Foundation/Auth/User.php
class User extends Model implements
    AuthenticatableContract,
    AuthorizableContract,
    CanResetPasswordContract
{
    use Authenticatable, Authorizable, CanResetPassword, MustVerifyEmail;
}

MustVerifyEmailトレイトをuseしてますね。SendEmailVerificationNotificationクラスでもUserクラスのインスタンスがMustVerifyEmailインターフェースかどうかチェックしていました。MustVerifyEmailトレイトの方を見て見ましょう。

※ 実はここで気になる点があります。後ほど解説したいと思います。

vendor/laravel/framework/src/Illuminate/Auth/MustVerifyEmail.php
trait MustVerifyEmail
{

    public function hasVerifiedEmail()
    {
        return ! is_null($this->email_verified_at);
    }

    public function markEmailAsVerified()
    {
        return $this->forceFill([
            'email_verified_at' => $this->freshTimestamp(),
        ])->save();
    }

    public function sendEmailVerificationNotification()
    {
        $this->notify(new Notifications\VerifyEmail);
    }
}

sendEmailVerificationNotificationメソッドでnotifyしてます。そのままNotifications\VerifyEmailクラスを確認して見ましょう。

vendor/laravel/framework/src/Illuminate/Auth/Notifications/VerifyEmail.php
public function toMail($notifiable)
{
    if (static::$toMailCallback) {
        return call_user_func(static::$toMailCallback, $notifiable);
    }

    return (new MailMessage)
        ->subject(Lang::getFromJson('Verify Email Address'))
        ->line(Lang::getFromJson('Please click the button below to verify your email address.'))
        ->action(
            Lang::getFromJson('Verify Email Address'),
            $this->verificationUrl($notifiable)
        )
        ->line(Lang::getFromJson('If you did not create an account, no further action is required.'));
}

protected function verificationUrl($notifiable)
{
    return URL::temporarySignedRoute(
        'verification.verify', Carbon::now()->addMinutes(60), ['id' => $notifiable->getKey()]
    );
}

おめでとう!あなたは目的地に到達しました!このtoMailメソッドで確認メールを送っています。そしてverificationUrlメソッドで確認用のURLを作成しています。URL::temporarySignedRouteを使用していますので、60分で無効になる署名URLになっているようです。(Laravelの署名URLについてはこのへんを参考にしてください。)

メール記載のURLクリックから確認まで

メールに記載されているURLはverification.verifyというルート名でした。どうせVerificationControllerクラスのverifyメソッドなので見てみましょう(適当)

app/Http/Controllers/Auth/VerificationController.php
use Illuminate\Foundation\Auth\VerifiesEmails;

class VerificationController extends Controller
{
    use VerifiesEmails;

    public function __construct()
    {
        $this->middleware('auth');
        $this->middleware('signed')->only('verify');
        $this->middleware('throttle:6,1')->only('verify', 'resend');
    }
}

例によってtraitで実装してるみたいです。Illuminate\Foundation\Auth\VerifiesEmailsトレイトに進みましょう。

vendor/laravel/framework/src/Illuminate/Foundation/Auth/VerifiesEmails.php
public function verify(Request $request)
{
    if ($request->route('id') == $request->user()->getKey() &&
        $request->user()->markEmailAsVerified()) {
        event(new Verified($request->user()));
    }

    return redirect($this->redirectPath());
}

UserクラスのmarkEmailAsVerifiedメソッドでいかにも何かしてそうです。このmarkEmailAsVerifiedメソッドはUserクラスが継承するIlluminate\Foundation\Auth\UserクラスでuseしているMustVerifyEmailトレイトに実装されていましたね。すでに一度引用していますが、以下再掲します。

vendor/laravel/framework/src/Illuminate/Auth/MustVerifyEmail.php
trait MustVerifyEmail
{
    public function markEmailAsVerified()
    {
        return $this->forceFill([
            'email_verified_at' => $this->freshTimestamp(),
        ])->save();
    }
}

email_verified_atカラムを更新しています。このemail_verified_atカラムは
(公式のリリースノート)[https://laravel.com/docs/5.7/releases#laravel-5.7] にも書いてあるように、今回の更新でu追加されました。一応マイグレーションファイルを確認してみましょう。

database/migrations/2014_10_12_000000_create_users_table.php
Schema::create('users', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable(); /// ← こいつが追加
    $table->string('password');
    $table->rememberToken();
    $table->timestamps();
});

さぁ、これでメール確認が完了しました。ですが、メール確認したかどうかのチェックをしている箇所も確認しておいたほうがいいでしょう。

メール確認が済んでいるかどうかのチェックはどこ?

ユーザーの認証チェックはミドルウェアでやってました。なのでメール確認済みかどうかもミドルウェアでやってるに違いありません(根拠なし)。なのでKernelクラスを確認して該当するミドルウェアがないかみて見ましょう。

app/Http/Kernel.php
protected $routeMiddleware = [
    'auth' => \App\Http\Middleware\Authenticate::class,
    'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
    'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
    'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
    'can' => \Illuminate\Auth\Middleware\Authorize::class,
    'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
    'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
    'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
    'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, /// ← きっとこいつ
];

あった(一勝)。Illuminate\Auth\Middleware\EnsureEmailIsVerifiedクラスに進みます。

vendor/laravel/framework/src/Illuminate/Auth/Middleware/EnsureEmailIsVerified.php
class EnsureEmailIsVerified
{
    public function handle($request, Closure $next)
    {
        if (! $request->user() ||
            ($request->user() instanceof MustVerifyEmail &&
            ! $request->user()->hasVerifiedEmail())) {
            return $request->expectsJson()
                    ? abort(403, 'Your email address is not verified.')
                    : Redirect::route('verification.notice');
        }

        return $next($request);
    }
}

UserクラスのhasVerifiedEmailメソッドでチェックしてそうです。このhasVerifiedEmailメソッドはUserクラスが継承するIlluminate\Foundation\Auth\UserクラスでuseしているMustVerifyEmailトレイトに実装されていましたね。(コピペ)

vendor/laravel/framework/src/Illuminate/Auth/MustVerifyEmail.php
trait MustVerifyEmail
{
    public function hasVerifiedEmail()
    {
        return ! is_null($this->email_verified_at);
    }

email_verified_atをチェックしています。これで5.7の標準認証もバッチリだな?

気になる点(2019.09.15 内容修正)

さて、注釈で言っていた気になる点についで話をしてみます。

Illuminate\Foundation\Auth\Userクラスの基本的な実装方針としては、「interfaceをimplementしておいて、traitで実装を書く」形になってます。このクラスは3つのインターフェイスをimplementしていますが、各々のtraitとの対応関係は以下のようになります。

interface trait
Illuminate\Contracts\Auth\Authenticatable(AuthenticatableContract) Illuminate\Auth\Authenticatable
Illuminate\Contracts\Auth\Access\Authorizable(AuthorizableContract) Illuminate\Foundation\Auth\Access\Authorizable
Illuminate\Contracts\Auth\CanResetPassword(CanResetPasswordContract) Illuminate\Auth\Passwords\CanResetPassword

本来であれば、Illuminate/Foundation/Auth/UserクラスはIlluminate\Contracts\Auth\MustVerifyEmailインターフェイスをimplementした上でIlluminate\Auth\MustVerifyEmailトレイトをuseするのが正しい流れなのですが、なぜかUserクラスはMustVerifyEmailインターフェイスをimplementしていません。

なぜでしょう?
これはEmail Verificationの機能を使いたい場合のみ使えるようにするためのようです。確認メールを送信していたSendEmailVerificationNotificationクラスをもう一度見てみましょう。

vendor/laravel/framework/src/Illuminate/Auth/Listeners/SendEmailVerificationNotification.php
public function handle(Registered $event)
{
    if ($event->user instanceof MustVerifyEmail && ! $event->user->hasVerifiedEmail()) {
        $event->user->sendEmailVerificationNotification();
    }
}

instanceof MustVerifyEmailでチェックしています。つまり、MustVerifyEmailインターフェイスをimplementsしていない場合はメールが送信されないようになっています。このおかげで、App\UserクラスでMustVerifyEmailインターフェイスをimplementしない限りメール確認機能が使えない形になっています。

念のためrouteをちゃんと確認する?

routeファイルで認証のためのAuth::routes();というコードが書かれていますが、引数にちょっと足します。

routes/web.php
Auth::routes(['verify' => true]);

こうするとEmail Verification周りのrouteが設定されます。どんなrouteか見てみましょう。

ルート名 アクション ミドルウェア
verification.resend App\Http\Controllers\Auth\VerificationController@resend web,auth,throttle:6,1
verification.notice App\Http\Controllers\Auth\VerificationController@show web,auth
verification.verify App\Http\Controllers\Auth\VerificationController@verify web,auth,signed,throttle:6,1

予想通りだな(小並感)

終わりに

間違いあったら指摘おなしゃーす。