LoginSignup
1
0

Lravel マルチログイン メール認証カスタマイズ

Posted at

Laravel Breezeで新規登録時にメール認証をした後、トップページに遷移させたい

UserテーブルとAdminテーブルがあります。
UserとAdminとではメール認証後のトップページは違うのでそれぞれで分けたいので、メール認証機能をカスタマイズして実装をしました。

マルチ認証の設定

1.Laravel Breezeを導入

マイグレーションファイルとモデルを作成

Userテーブル

_create_users_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            //email_verified_atがあるか確認
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

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

MustVerifyEmailを追加し、新規ユーザーにメール認証リンクを送信するプロセスを設定します

Adminテーブル

_create_admins_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('admins', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            //email_verified_atがあるか確認
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

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

guardsを作成

config.auth.php
'guards' => [
        'user' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'admin' => [
            'driver' => 'session',
            'provider' => 'admins',
        ],

/**
* 省略
*/

        'providers' => [
                'users' => [
                    'driver' => 'eloquent',
                    'model' => App\Models\User::class,
                ],
                'admins' => [
                    'driver' => 'eloquent',
                    'model' => App\Models\Admin::class,
                ],

/**
* 省略
*/
        'passwords' => [
            'users' => [
                'provider' => 'users',
                'table' => 'password_reset_tokens',
                'expire' => 60,
                'throttle' => 60,
            ],
                'admins' => [
                'provider' => 'admins',
                'table' => 'password_resets',
                'expire' => 60,
            ],

Requests/Auth/LoginRequest.phpのauthenticateメソッドを修正します

LoginRequest.php
<?php

namespace App\Http\Requests\Auth;

use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class LoginRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email'],
            'password' => ['required', 'string'],
        ];
    }

    /**
     * Attempt to authenticate the request's credentials.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function authenticate(): void
    {
        $this->ensureIsNotRateLimited();

        $guardName = 'user';
        if ($this->is('admin/*')) {
            $guardName = 'admin';
        } 

        if (! Auth::guard($guardName)->attempt($this->only('email', 'password'), $this->boolean('remember'))) {
            RateLimiter::hit($this->throttleKey());

            throw ValidationException::withMessages([
                'email' => trans('auth.failed'),
            ]);
        }

        RateLimiter::clear($this->throttleKey());
    }

    /**
     * Ensure the login request is not rate limited.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function ensureIsNotRateLimited(): void
    {
        if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
            return;
        }

        event(new Lockout($this));

        $seconds = RateLimiter::availableIn($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => trans('auth.throttle', [
                'seconds' => $seconds,
                'minutes' => ceil($seconds / 60),
            ]),
        ]);
    }

    /**
     * Get the rate limiting throttle key for the request.
     */
    public function throttleKey(): string
    {
        return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip());
    }
}

ミドルウェアでルートを定義

メール送信後に元の画面(新規登録画面)を表示させるには、以下のルートはミドルウェアに含めません。
今回はUser/Auth/RegisteredUserController.phpで新規登録時にguardでログイン状態にしているため、ミドルウェアを適応させるとルートがないといったエラーが起きます。
コードは下記にあります。

 Route::get('login', [UserAuthenticatedSessionController::class, 'create'])->name('user.login');

    Route::get('register', [RegisteredUserController::class, 'create'])
    ->name('user.register');

UserとAdminのルートを定義します。

route/auth.php
<?php

// ユーザー向けのコントローラー
use App\Http\Controllers\User\Auth\AuthenticatedSessionController as UserAuthenticatedSessionController;
use App\Http\Controllers\User\Auth\ConfirmablePasswordController as UserConfirmablePasswordController;
use App\Http\Controllers\User\Auth\EmailVerificationNotificationController as UserEmailVerificationNotificationController;
use App\Http\Controllers\User\Auth\EmailVerificationPromptController as UserEmailVerificationPromptController;
use App\Http\Controllers\User\Auth\NewPasswordController as UserNewPasswordController;
use App\Http\Controllers\User\Auth\PasswordController as UserPasswordController;
use App\Http\Controllers\User\Auth\PasswordResetLinkController as UserPasswordResetLinkController;
use App\Http\Controllers\User\Auth\RegisteredUserController;
use App\Http\Controllers\User\Auth\VerifyEmailController as UserVerifyEmailController;

// 管理者向けのコントローラー
use App\Http\Controllers\Admin\Auth\AuthenticatedSessionController as AdminAuthenticatedSessionController;
use App\Http\Controllers\Admin\Auth\ConfirmablePasswordController as AdminConfirmablePasswordController;
use App\Http\Controllers\Admin\Auth\EmailVerificationNotificationController as AdminEmailVerificationNotificationController;
use App\Http\Controllers\Admin\Auth\EmailVerificationPromptController as AdminEmailVerificationPromptController;
use App\Http\Controllers\Admin\Auth\NewPasswordController as AdminNewPasswordController;
use App\Http\Controllers\Admin\Auth\PasswordController as AdminPasswordController;
use App\Http\Controllers\Admin\Auth\PasswordResetLinkController as AdminPasswordResetLinkController;
use App\Http\Controllers\Admin\Auth\RegisteredUserController as AdminRegisteredUserController;
use App\Http\Controllers\Admin\Auth\VerifyEmailController as AdminVerifyEmailController;

// ユーザー向けの認証ルート
Route::prefix('user')->group(function () {
    Route::get('login', [UserAuthenticatedSessionController::class, 'create'])->name('user.login');

    Route::get('register', [RegisteredUserController::class, 'create'])
    ->name('user.register');
    // ユーザー向けの認証ルート
    Route::middleware('guest:user')->group(function () {

        Route::post('register', [RegisteredUserController::class, 'store'])->name('user.register.store');

        Route::post('login', [UserAuthenticatedSessionController::class, 'store'])
            ->name('user.login.store');

        Route::get('forgot-password', [UserPasswordResetLinkController::class, 'create'])
            ->name('user.password.request');

        Route::post('forgot-password', [UserPasswordResetLinkController::class, 'store'])
            ->name('user.password.email');

        Route::get('reset-password/{token}', [UserNewPasswordController::class, 'create'])
            ->name('password.reset');

        Route::post('reset-password',[UserNewPasswordController::class, 'store'])
            ->name('password.store');
    });
    // 認証済みのユーザールート
    Route::middleware('auth:user')->group(function () {
        Route::get('verify-email', UserEmailVerificationPromptController::class)
            ->name('user.verification.notice');

        Route::get('verify-email/{id}/{hash}', UserVerifyEmailController::class)
            ->middleware(['signed', 'throttle:6,1'])
            ->name('user.verification.verify');

        Route::post('email/verification-notification', [UserEmailVerificationNotificationController::class, 'store'])
            ->middleware('throttle:6,1')
            ->name('user.verification.send');

        Route::get('confirm-password', [UserConfirmablePasswordController::class, 'show'])
            ->name('user.password.confirm');

        Route::post('confirm-password', [UserConfirmablePasswordController::class, 'store']);

        Route::put('password', [UserPasswordController::class, 'update'])->name('user.password.update');

        Route::post('logout', [UserAuthenticatedSessionController::class, 'destroy'])
            ->name('user.logout');
    });

    //メールのリンクを踏んだ後は、以下のルートに遷移させるため用意します
    Route::controller(UserController::class)->group(function () {
            Route::get('/home', 'home')->name('user.home');
        });
});

// 管理者向けの認証ルート
Route::prefix('admin')->name('admin.')->group(function () {
    Route::middleware('guest:admin')->group(function () {
        Route::get('login', [AdminAuthenticatedSessionController::class, 'create'])->name('login');
        Route::post('login', [AdminAuthenticatedSessionController::class, 'store'])->name('login.store');
        Route::get('register', [AdminRegisteredUserController::class, 'create'])->name('register');
        Route::post('register', [AdminRegisteredUserController::class, 'store'])->name('register.store');
        Route::get('forgot-password', [AdminPasswordResetLinkController::class, 'create'])->name('password.request');
        Route::post('forgot-password', [AdminPasswordResetLinkController::class, 'store'])->name('password.email');
        Route::get('reset-password/{token}', [AdminNewPasswordController::class, 'create'])->name('password.reset');
        Route::post('reset-password', [AdminNewPasswordController::class, 'store'])->name('password.update');
    });

    Route::middleware('auth:admin')->group(function () {
        Route::get('verify-email', [AdminEmailVerificationPromptController::class, '__invoke'])->name('verification.notice');
        Route::get('verify-email/{id}/{hash}', [AdminVerifyEmailController::class, '__invoke'])->name('verification.verify')->middleware(['signed', 'throttle:6,1']);
        Route::post('email/verification-notification', [AdminEmailVerificationNotificationController::class, 'store'])->name('verification.send')->middleware('throttle:6,1');
        Route::get('confirm-password', [AdminConfirmablePasswordController::class, 'show'])->name('password.confirm');
        Route::post('confirm-password', [AdminConfirmablePasswordController::class, 'store']);
        Route::post('logout', [AdminAuthenticatedSessionController::class, 'destroy'])->name('logout');
    });
});

コントローラー内にUserとAdminフォルダを作成

Laravel Breezeで作成された、Authフォルダをコピ-して、UserフォルダとAdminフォルダ内に貼り付けます。

3.resources内のviewsフォルダにAdmin、Userフォルダを作成。

Laravel Breezeで作成された、Authフォルダをコピ-して、UserフォルダとAdminフォルダ内に貼り付けます。

Adminの場合

- app
  - Http
    - Controllers
      - User
        - Auth

          //例
          - AuthenticatedSessionController.php
          - //以下ファイル

namespace をnamespace App\Http\Controllers\User\Authとします。

<?php

namespace App\Http\Controllers\User\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

//各コントローラー

マルチ認証の実装はこれで完了です。
メール認証の実装に入ります。

メール認証

モデルの準備

  • UserモデルにIlluminate\Contracts\Auth\MustVerifyEmailを実装します。このインターフェースをモデルに実装することで、そのモデル(この場合は Admin モデル)のインスタンスがメールアドレスの確認を必要とすることをLaravelに伝えます。これにより、ユーザーがアカウントを作成した後、メールアドレスを確認するまで、認証が必要な機能やページへのアクセスが制限されます。
  • Notifiableでモデルインスタンスを通じて様々なタイプの通史を送信できるようにします。
Model/User.php
<?php

namespace App\Models;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable implements MustVerifyEmail
{
    use Notifiable;

    // ...
}

新規登録コントローラー設定

User/Auth/RegisteredUserController.php
<?php

namespace App\Http\Controllers\User\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;

class RegisteredUserController extends Controller
{
    /**
     * Display the registration view.
     */
    public function create(): View
    {
        return view('user.auth.register');
    }

    /**
     * Handle an incoming registration request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:'.Admin::class],
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
        ]);

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        event(new Registered($user));

        Auth::guard('user')->login($user);

        return redirect()->route('user.register');
}

ここで新規登録アクションを実行すると、verification.verify not definedとなります。

メール確認通知を返すルートの名前は verification.noticeにする必要があります。Laravelが用意しているverifiedミドルウェアは、ユーザーがメールアドレスを確認していない場合、このルート名に自動的にリダイレクトするため、ルートへ正確にこの名前を割り当てることが重要です。

しかし、ルートに名前をつけているのでデフォルトのままでは動作しません。
メール認証をカスタマイズします

Route::get('verify-email/{id}/{hash}', UserVerifyEmailController::class)
            ->middleware(['signed', 'throttle:6,1'])
            ->name('user.verification.verify');

ここでVerifyEmailControllerの以下のようにします。

User/Auth/VerifyEmailController.php
<?php

namespace App\Http\Controllers\User\Auth;

use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;

class VerifyEmailController extends Controller
{
    /**
     * メールアドレスを認証する
     */
    public function __invoke(EmailVerificationRequest $request): RedirectResponse
    {

        //メール認証されている時のリダイレクト先
        if ($request->user()->hasVerifiedEmail()) {
            return redirect()->route('user.home');
        }

        // この行は、ユーザーのメールアドレスが確認されたときに、Verified イベントを発火させるコード
        if ($request->user()->markEmailAsVerified()) {

            event(new Verified($request->user()));

        }

        //最終的に任意のルート先にリダイレクトさせるようにします
        return redirect()->route('user.home');
    }
}

カスタムメール認証作成

Laravelのartisanコマンドを使用して、新しい通知クラスを生成します。ターミナルで以下のコマンドを実行してください。

php artisan make:notification UserCustomVerifyEmail

中身は以下のようにします。

app/Notifications/UserCustomVerifyEmail.php
<?php

namespace App\Notifications;

use Illuminate\Auth\Notifications\VerifyEmail as VerifyEmailBase;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;

class UserCustomVerifyEmail extends VerifyEmailBase
{
    /**
     * メールアドレスの確認URLを生成
     */
    protected function verificationUrl($notifiable)
    {
        return URL::temporarySignedRoute(
            //ここに名前付きルートを記述
            'user.verification.verify',
            Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
            ['id' => $notifiable->getKey(), 'hash' => sha1($notifiable->getEmailForVerification())]
        );
    }

    /**
     * メールアドレスの確認メールを送信
     */
    public function toMail($notifiable)
    {
        $verificationUrl = $this->verificationUrl($notifiable);

        return (new MailMessage)
            ->subject('メールアドレスの確認')
            ->line('以下のボタンをクリックしてメールアドレスを確認してください。')
            ->action('メールアドレスを確認', $verificationUrl);
    }
}

解説

VerifyEmailBaseを継承します。「継承」とは、とあるクラスで定義されているものを引き継ぐことです。VerifyEmailBaseは以下のようになっています。

<?php

namespace Illuminate\Auth\Notifications;

use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\URL;

class VerifyEmail extends Notification
{
    /**
     * The callback that should be used to create the verify email URL.
     *
     * @var \Closure|null
     */
    public static $createUrlCallback;

    /**
     * The callback that should be used to build the mail message.
     *
     * @var \Closure|null
     */
    public static $toMailCallback;

    /**
     * Get the notification's channels.
     *
     * @param  mixed  $notifiable
     * @return array|string
     */
    public function via($notifiable)
    {
        return ['mail'];
    }

    /**
     * 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);
    }

    /**
     * Get the verify email notification mail message for the given URL.
     *
     * @param  string  $url
     * @return \Illuminate\Notifications\Messages\MailMessage
     */
    protected function buildMailMessage($url)
    {
        return (new MailMessage)
            ->subject(Lang::get('Verify Email Address'))
            ->line(Lang::get('Please click the button below to verify your email address.'))
            ->action(Lang::get('Verify Email Address'), $url)
            ->line(Lang::get('If you did not create an account, no further action is required.'));
    }

    /**
     * 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()),
            ]
        );
    }

    /**
     * Set a callback that should be used when creating the email verification URL.
     *
     * @param  \Closure  $callback
     * @return void
     */
    public static function createUrlUsing($callback)
    {
        static::$createUrlCallback = $callback;
    }

    /**
     * Set a callback that should be used when building the notification mail message.
     *
     * @param  \Closure  $callback
     * @return void
     */
    public static function toMailUsing($callback)
    {
        static::$toMailCallback = $callback;
    }
}

ここでverification.verifyが呼ばれているので「継承」してカスタマイズする必要があります。

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()),
            ]
        );
    }

オーバーライド

sendEmailVerificationNotification メソッドは、ユーザーにメールアドレス確認の通知を送信するために使用されます。Laravelでは、MustVerifyEmail インターフェースを実装したモデルは、デフォルトでメールアドレス確認の通知を送信する機能を持っていますが、sendEmailVerificationNotification メソッドをオーバーライドすることで、この通知の送信方法をカスタマイズできます。

Model/User.php
<?php

namespace App\Models;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use App\Notifications\UserCustomVerifyEmail; //追加

class User extends Authenticatable implements MustVerifyEmail
{
    use Notifiable;

    // ...

    /**
     * メール認証通知の送信
     */
    public function sendEmailVerificationNotification()
    {
        $this->notify(new UserCustomVerifyEmail);
    }
}
ここまでのステップ

1.新規登録後、メールを送信。
2.画面は新規登録画面のまま。
3.届いたメールのリンクを踏む
4.user.homeにリダイレクト

正しければ、新規登録時はemail_verified_atがnullだったのが、メール認証後はリンクをクリックした日付が正しく入力されているのが確認されます。

メール認証していないユーザーは制限したいとき

つまりemail_verified_atがnullの時はトップページを表示させず、ログインページにリダイレクトさせます。

ミドルウェアの作成

pp/Http/Middleware ディレクトリ内に UserRedirectIfEmailNotVerified.php という名前の新しいミドルウェアクラスファイルが作成されます。

php  artisan make:middleware UserRedirectIfEmailNotVerified
UserRedirectIfEmailNotVerified.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class UserRedirectIfEmailNotVerified
{
    /**
     * メールアドレスが確認されていない場合は、確認メールを再送信する
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        // ユーザーがログインしていて、かつemail_verified_atがnullの場合
        if (Auth::check() && Auth::user()->email_verified_at === null) {

            // メールアドレスの確認メールを再送信
            Auth::user()->sendEmailVerificationNotification();


            return redirect()->route('user.login');
        }

        return $next($request);
    }
}

ミドルウェアの登録

app/Http/Kernel.phprouteMiddleware内に定義します。

Kernel.php
protected $routeMiddleware = [
        'user.email.verified' => \App\Http\Middleware\UserRedirectIfEmailNotVerified::class,
    ];

ログイン後のリダイレクト先に設定します。今回ではログイン後user.homeというルートに遷移するので以下のように設定します。

auth/route.php
Route::controller(UserController::class)->group(function () {
            Route::get('/home', 'home')->name('user.home');
        })->middleware('user.email.verified');

ここまでのステップ

1.ログインフォームに入力後、ログインボタンを押す
2.user.homeの画面に遷移する前にuser.email.verifiedが発火
3.email_verified_atがnullでないか確認。nullだった場合、認証メールを送信。値が正常の時はuser.homeを表示。

同じようにAdminもカスタムメール認証を作成すれば実装できます。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0