Laravel9でFortifyを使ってみる

概要

Fortifyはユーザ登録やパスワードリセットなどユーザ認証系の主なルート登録とコントローラーを提供するパッケージで、具体的にはユーザの登録・ログイン認証・パスワードリセット・ユーザ情報更新・2要素認証(2FA)のバックエンドを担当してくれます。ユーザーインターフェースはご自由にどうぞということですね。今回はLaravel9でFortifyにできることを確かめつつ実装してみます。

開発環境

Windows10
Docker Desktop
WSL2
Laravel Sail
Bootstrap

Laravel9インストール&設定

Laravel/UIのauthで提供されていたViewがほぼそのまま利用できそうなので参考にさせてもらいます。

Laravel9のインストール

$ curl -s https://laravel.build/fortify | bash
$ cd fortify
$ sail up -d

Bootstrapインストール

$ sail composer require laravel/ui
$ sail artisan ui bootstrap

Viteの設定

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: [
                'resources/css/app.css',
                'resources/js/app.js',
            ],
            refresh: true,
        }),
    ],
    // ここから追加
    server: {
        hmr: {
            host: 'localhost',
        },
    },
    // ここまで
});

app.js

import './bootstrap';
import '../sass/app.scss';	// 追加

Fortifyインストール&設定

Composerでlaravel/fortifyをインストールしてFortifyServiceProviderの配置し、必要なテーブル作成します。

$ sail composer require laravel/fortify
$ sail artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
$ sail artisan migrate

$ sail npm install
$ sail npm run dev

FortifyServiceProviderをプロバイダーとして登録

'providers' => [
	…
	App\Providers\FortifyServiceProvider::class,	// 追加
],

Fortifyの機能について

インストールされた内容からFortifyの機能についてみておきましょう。

設定ファイル config/fortify.php

    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        // Features::emailVerification(),
        Features::updateProfileInformation(),
        Features::updatePasswords(),
        Features::twoFactorAuthentication([
            'confirm' => true,
            'confirmPassword' => true,
            // 'window' => 0,
        ]),
    ],

Fortifyの設定ファイルで用意されているのは以下の機能ですね。
・ユーザ登録
・リセットパスワード(パスワードを忘れた場合の再設定)
・メール確認(検証)
・プロフィール情報更新
・パスワード更新
・2要素認証

生成されたRoute

$ sail artisan route:list

  GET|HEAD  / .................................................................................................................................
  POST      _ignition/execute-solution .......................... ignition.executeSolution › Spatie\LaravelIgnition › ExecuteSolutionController
  GET|HEAD  _ignition/health-check ...................................... ignition.healthCheck › Spatie\LaravelIgnition › HealthCheckController
  POST      _ignition/update-config ................................... ignition.updateConfig › Spatie\LaravelIgnition › UpdateConfigController
  GET|HEAD  api/user ..........................................................................................................................
  GET|HEAD  forgot-password ........................................... password.request › Laravel\Fortify › PasswordResetLinkController@create
  POST      forgot-password .............................................. password.email › Laravel\Fortify › PasswordResetLinkController@store
  GET|HEAD  home ..............................................................................................................................
  GET|HEAD  login ............................................................. login › Laravel\Fortify › AuthenticatedSessionController@create
  POST      login ...................................................................... Laravel\Fortify › AuthenticatedSessionController@store
  POST      logout .......................................................... logout › Laravel\Fortify › AuthenticatedSessionController@destroy
  GET|HEAD  register ............................................................. register › Laravel\Fortify › RegisteredUserController@create
  POST      register ......................................................................... Laravel\Fortify › RegisteredUserController@store
  POST      reset-password .................................................... password.update › Laravel\Fortify › NewPasswordController@store
  GET|HEAD  reset-password/{token} ............................................ password.reset › Laravel\Fortify › NewPasswordController@create
  GET|HEAD  sanctum/csrf-cookie ............................................. sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show
  GET|HEAD  two-factor-challenge .......................... two-factor.login › Laravel\Fortify › TwoFactorAuthenticatedSessionController@create
  POST      two-factor-challenge .............................................. Laravel\Fortify › TwoFactorAuthenticatedSessionController@store
  GET|HEAD  user/confirm-password ........................................................ Laravel\Fortify › ConfirmablePasswordController@show
  POST      user/confirm-password .................................... password.confirm › Laravel\Fortify › ConfirmablePasswordController@store
  GET|HEAD  user/confirmed-password-status ................... password.confirmation › Laravel\Fortify › ConfirmedPasswordStatusController@show
  POST      user/confirmed-two-factor-authentication .. two-factor.confirm › Laravel\Fortify › ConfirmedTwoFactorAuthenticationController@store
  PUT       user/password .................................................. user-password.update › Laravel\Fortify › PasswordController@update
  PUT       user/profile-information .................. user-profile-information.update › Laravel\Fortify › ProfileInformationController@update
  POST      user/two-factor-authentication ...................... two-factor.enable › Laravel\Fortify › TwoFactorAuthenticationController@store
  DELETE    user/two-factor-authentication ................... two-factor.disable › Laravel\Fortify › TwoFactorAuthenticationController@destroy
  GET|HEAD  user/two-factor-qr-code ..................................... two-factor.qr-code › Laravel\Fortify › TwoFactorQrCodeController@show
  GET|HEAD  user/two-factor-recovery-codes ......................... two-factor.recovery-codes › Laravel\Fortify › RecoveryCodeController@index
  POST      user/two-factor-recovery-codes ..................................................... Laravel\Fortify › RecoveryCodeController@store
  GET|HEAD  user/two-factor-secret-key ............................ two-factor.secret-key › Laravel\Fortify › TwoFactorSecretKeyController@show

作成しなければならない画面

ログイン前
・ユーザ登録画面 resources/views/auth/register.blade.php
・ログイン画面 resources/views/auth/login.blade.php
・パスワードリセット申請画面 resources/views/auth/forgot-password.blade.php
・パスワードリセット画面 resources/views/auth/reset-password.blade.php

ログイン後
・パスワード変更画面 resources/views/profile/password.blade.php
・プロフィール変更画面 resources/views/profile/edit.blade.php
・2要素認証QRコード表示画面 resources/views/auth/two-factor-authentication.blade.php
・2要素認証用パスワード再入力画面 resources/views/auth/confirm-password.blade.php
・2要素認証コード入力画面 resources/views/auth/two-factor-challenge.blade.php

一部のViewはFortifyServiceProviderに登録することで、Fortifyと連携してRouteも確保してくれるようなので随時設定していきます。最終的には以下のようになるかと思います。

public function boot()
    {
        Fortify::registerView(fn () => view('auth.register'));
        Fortify::loginView(fn () => view('auth.login'));
        
        Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));
        Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));
        
        Fortify::verifyEmailView(fn () => view('auth.verify-email'));
        
        Fortify::confirmPasswordView(fn () => view('auth.confirm-password'));
        Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge'));
        …

ログイン後の2つの画面(パスワード変更画面とプロフィール変更画面)についてView設定は用意されていないのでRouteを個別に設定する必要がありそうです。

準備

Bladeのレイアウトファイルとログイン後のユーザーホーム画面を作成し、ユーザーホーム画面のRoute設定を行っておきます。

Bladeレイアウトファイル作成

<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Fonts -->
    <link rel="dns-prefetch" href="//fonts.gstatic.com">
    <link href="https://fonts.bunny.net/css?family=Nunito" rel="stylesheet">

    <!-- Scripts -->
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    <div id="app">
        <nav class="navbar navbar-expand-md navbar-light bg-white shadow-sm">
            <div class="container">
                <a class="navbar-brand" href="{{ url('/') }}">
                    {{ config('app.name', 'Laravel') }}
                </a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="{{ __('Toggle navigation') }}">
                    <span class="navbar-toggler-icon"></span>
                </button>

                <div class="collapse navbar-collapse" id="navbarSupportedContent">
                    <!-- Left Side Of Navbar -->
                    <ul class="navbar-nav me-auto">

                    </ul>

                    <!-- Right Side Of Navbar -->
                    <ul class="navbar-nav ms-auto">
                        <!-- Authentication Links -->
                        @guest
                            @if (Route::has('login'))
                                <li class="nav-item">
                                    <a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a>
                                </li>
                            @endif

                            @if (Route::has('register'))
                                <li class="nav-item">
                                    <a class="nav-link" href="{{ route('register') }}">{{ __('Register') }}</a>
                                </li>
                            @endif
                        @else
                            <li class="nav-item dropdown">
                                <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
                                    {{ Auth::user()->name }}
                                </a>

                                <div class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
                                    <a class="dropdown-item" href="{{ route('logout') }}"
                                       onclick="event.preventDefault();
                                                     document.getElementById('logout-form').submit();">
                                        {{ __('Logout') }}
                                    </a>

                                    <a class="dropdown-item" href="{{ route('user-password.edit') }}">
                                        {{ __('Change Password') }}
                                    </a>
                                    <a class="dropdown-item" href="{{ route('user-profile-information.edit') }}">
                                        {{ __('Edit Profile') }}
                                    </a>
                                    <a class="dropdown-item" href="{{ route('auth-two-factor-authentication') }}">
                                        {{ __('2FA') }}
                                    </a>

                                    <form id="logout-form" action="{{ route('logout') }}" method="POST" class="d-none">
                                        @csrf
                                    </form>
                                </div>
                            </li>
                        @endguest
                    </ul>
                </div>
            </div>
        </nav>

        <main class="py-4">
            @yield('content')
        </main>
    </div>
</body>
</html>

予め「パスワード更新」「プロフィール情報更新」「2要素認証(2FA)」のドロップダウンメニューを追加しています。

ログイン後のユーザーホーム画面作成

@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">{{ __('Dashboard') }}</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

                    {{ __('You are logged in!') }}
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
user-menu

ユーザーホームのRoute追加

ユーザーホームを要認証画面としておきます。

Route::middleware('auth')->group(function(){
    Route::view('/home', 'home')->name('user-home');
});

ユーザー登録機能の実装

Fortifyにユーザー登録画面を設定

public function boot()
    {
        Fortify::registerView(fn () => view('auth.register'));	// 追加
        …

ユーザー登録画面の作成

@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">{{ __('Register') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('register') }}">
                        @csrf

                        <div class="row mb-3">
                            <label for="name" class="col-md-4 col-form-label text-md-end">{{ __('Name') }}</label>

                            <div class="col-md-6">
                                <input id="name" type="text" class="form-control @error('name') is-invalid @enderror" name="name" value="{{ old('name') }}" required autocomplete="name" autofocus>

                                @error('name')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email">

                                @error('email')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="new-password">

                                @error('password')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="password-confirm" class="col-md-4 col-form-label text-md-end">{{ __('Confirm Password') }}</label>

                            <div class="col-md-6">
                                <input id="password-confirm" type="password" class="form-control" name="password_confirmation" required autocomplete="new-password">
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Register') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
fortify_register

ログイン機能の実装

Fortifyにログイン画面を登録

public function boot()
    {
        Fortify::registerView(fn () => view('auth.register'));
        Fortify::loginView(fn () => view('auth.login'));	// 追加
        …

ログイン画面の作成

@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">{{ __('Login') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('login') }}">
                        @csrf

                        <div class="row mb-3">
                            <label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email" autofocus>

                                @error('email')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="current-password">

                                @error('password')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <div class="col-md-6 offset-md-4">
                                <div class="form-check">
                                    <input class="form-check-input" type="checkbox" name="remember" id="remember" {{ old('remember') ? 'checked' : '' }}>

                                    <label class="form-check-label" for="remember">
                                        {{ __('Remember Me') }}
                                    </label>
                                </div>
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Login') }}
                                </button>

                                @if (Route::has('password.request'))
                                    <a class="btn btn-link" href="{{ route('password.request') }}">
                                        {{ __('Forgot Your Password?') }}
                                    </a>
                                @endif
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
fortify_login

リセットパスワード機能の実装

Fortifyの設定

public function boot()
    {
        Fortify::registerView(fn () => view('auth.register'));
        Fortify::loginView(fn () => view('auth.login'));
        Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));	// 追加
        Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));	// 追加
        …

パスワードリセット申請画面の作成

@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">{{ __('Reset Password') }}</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

                    <form method="POST" action="{{ route('password.email') }}">
                        @csrf

                        <div class="row mb-3">
                            <label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email" autofocus>

                                @error('email')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Send Password Reset Link') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
fortify_forgot_password

パスワードリセット画面の作成

tokenという名前のhiddenフィールドが必要で、UIパッケージのauthでは$tokenとなっているが、request()->route(‘token’)で取得して設定します。

@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">{{ __('Reset Password') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('password.update') }}">
                        @csrf

                        <input type="hidden" name="token" value="{{ request()->route('token') }}">

                        <div class="row mb-3">
                            <label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ $email ?? old('email') }}" required autocomplete="email" autofocus>

                                @error('email')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="new-password">

                                @error('password')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="password-confirm" class="col-md-4 col-form-label text-md-end">{{ __('Confirm Password') }}</label>

                            <div class="col-md-6">
                                <input id="password-confirm" type="password" class="form-control" name="password_confirmation" required autocomplete="new-password">
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Reset Password') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
fortify_reset_password

パスワード変更機能の実装

Route設定

Route::middleware('auth')->group(function(){
    Route::view('/home', 'home')->name('user-home');
    Route::view('/profile/password', 'profile.password')->name('user-password.edit');	// 追記
});

パスワード変更画面の作成

@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">{{ __('Change Password') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('user-password.update') }}">
                        @csrf
                        @method('PUT')

                        @if(session('status') == "password-updated")
                            <div class="alert alert-success">
                                Password updated successfully
                            </div>
                        @endif

                        <div class="row mb-3">
                            <label for="current_password" class="col-md-4 col-form-label text-md-end">{{ __('Current Password') }}</label>

                            <div class="col-md-6">
                                <input id="current_password" type="password" class="form-control @error('current_password', 'updatePassword') is-invalid @enderror" name="current_password" required autofocus>

                                @error('current_password', 'updatePassword')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password', 'updatePassword') is-invalid @enderror" name="password" required autocomplete="new-password">

                                @error('password', 'updatePassword')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="password-confirm" class="col-md-4 col-form-label text-md-end">{{ __('Confirm Password') }}</label>

                            <div class="col-md-6">
                                <input id="password-confirm" type="password" class="form-control" name="password_confirmation" required autocomplete="new-password">
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Save') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
fortify_change_password

プロフィール変更機能の実装

Route設定

Route::middleware('auth')->group(function(){
    Route::view('/home', 'home')->name('user-home');
    Route::view('/profile/password', 'profile.password')->name('user-password.edit');
    Route::view('/profile/edit', 'profile.edit')->name('user-profile-information.edit');	// 追記
});

プロフィール変更画面作成

@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">{{ __('Edit Profile') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('user-profile-information.update') }}">
                        @csrf
                        @method('PUT')

                        @if(session('status') == "profile-information-updated")
                            <div class="alert alert-success">
                                Profile updated successfully
                            </div>
                        @endif

                        <div class="row mb-3">
                            <label for="name" class="col-md-4 col-form-label text-md-end">{{ __('Name') }}</label>

                            <div class="col-md-6">
                                <input id="name" type="name" class="form-control @error('name') is-invalid @enderror" name="name" value="{{ old('name') ?? auth()->user()->name }}" required autocomplete="name" autofocus>

                                @error('name')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-3">
                            <label for="email" class="col-md-4 col-form-label text-md-end">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') ?? auth()->user()->email }}" required autocomplete="email" autofocus>

                                @error('email')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Update Profile') }}
                                </button>

                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
fortify_edit_profile

メール確認(検証)機能の実装

Userモデルの変更

// use Illuminate\Contracts\Auth\MustVerifyEmail;
↓
use Illuminate\Contracts\Auth\MustVerifyEmail;	// コメントアウト削除

class User extends Authenticatable
↓
class User extends Authenticatable implements MustVerifyEmail

Fortifyの設定

        // Features::emailVerification(),
        ↓
        Features::emailVerification(),	// コメントアウト削除

FortifyServiceProviderにメール確認(検証)画面を追加

public function boot()
    {
        Fortify::registerView(fn () => view('auth.register'));
        Fortify::loginView(fn () => view('auth.login'));
        Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));
        Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));
        Fortify::verifyEmailView(fn () => view('auth.verify-email'));	// 追加
        …

メール確認(検証)画面の作成

@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">{{ __('Verify Your Email Address') }}</div>

                <div class="card-body">
                    @if (session('resent'))
                        <div class="alert alert-success" role="alert">
                            {{ __('A fresh verification link has been sent to your email address.') }}
                        </div>
                    @endif

                    {{ __('Before proceeding, please check your email for a verification link.') }}
                    {{ __('If you did not receive the email') }},
                    <form class="d-inline" method="POST" action="{{ route('verification.send') }}">
                        @csrf
                        <button type="submit" class="btn btn-link p-0 m-0 align-baseline">{{ __('click here to request another') }}</button>.
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
fortify_verify_email

メール再送信のrouteはLaravel/UIのauthでは「verification.resend」になっていたがFortifyでは「verification.send」になっています。

Route設定

「verified」で要メール確認

Route::middleware('auth')->group(function(){
    Route::view('/home', 'home')->name('user-home');
    Route::view('/profile/password', 'profile.password')->name('user-password.edit');
    Route::view('/profile/edit', 'profile.edit')->name('user-profile-information.edit');
});
↓
Route::middleware('verified')->group(function(){
    Route::view('/home', 'home')->name('user-home');
    Route::view('/profile/password', 'profile.password')->name('user-password.edit');
    Route::view('/profile/edit', 'profile.edit')->name('user-profile-information.edit');
});

※この変更によってログイン後のプロフィール変更でメールアドレスを変更した場合もメール確認(検証)が必要となります。

2要素認証(2FA)機能の実装

Userモデルの変更

UserモデルにTwoFactorAuthenticatableトレイトを実装

use Laravel\Fortify\TwoFactorAuthenticatable;	// For Fortify

class User extends Authenticatable implements MustVerifyEmail
{
    use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable

    // ...
}

Fortifyの設定

パスワード再入力画面と2要素認証画面を追加

public function boot()
    {
        Fortify::registerView(fn () => view('auth.register'));
        Fortify::loginView(fn () => view('auth.login'));
        Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));
        Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));
        Fortify::verifyEmailView(fn () => view('auth.verify-email'));
        Fortify::confirmPasswordView(fn () => view('auth.confirm-password'));			// 追加
        Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge'));	// 追加
        …
    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        // Features::emailVerification(),
        Features::updateProfileInformation(),
        Features::updatePasswords(),
        Features::twoFactorAuthentication([
//            'confirm' => true,
            'confirmPassword' => true,
            // 'window' => 0,
        ]),
    ],

「’confirm’ => true」についてはよくわからないが、この設定ではQRコード画面が表示されなかったのでコメントアウトしておきます。

Route設定

Route::middleware('verified')->group(function(){
    Route::view('/home', 'home')->name('user-home');
    Route::view('/profile/password', 'profile.password')->name('user-password.edit');
    Route::view('/profile/edit', 'profile.edit')->name('user-profile-information.edit');
    Route::view('/auth/two-factor-authentication', 'auth.two-factor-authentication')->name('auth-two-factor-authentication');	// 追加
});

2要素認証QRコード表示画面の作成

@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">{{ __('Two factor Authentication') }}</div>

                <div class="card-body">
                    @if (session('status') === "two-factor-authentication-disabled")
                        <div class="alert alert-success" role="alert">
                            Two factor Authentication has been disabled.
                        </div>
                    @endif

                    @if (session('status') === "two-factor-authentication-enabled")
                        <div class="alert alert-success" role="alert">
                            Two factor Authentication has been enabled.
                        </div>
                    @endif

                    <form method="POST" action ="/user/two-factor-authentication">
                        @csrf

                        @if (auth()->user()->two_factor_secret)
                            @method('DELETE')

                            <div class="pb-5">
                                {!! auth()->user()->twoFactorQrCodeSvg() !!}
                            </div>

                            <div>
                                <h3>Recovery Codes:</h3>
                                <ul>
                                    @foreach (json_decode(decrypt(auth()->user()->two_factor_recovery_codes)) as $code)
                                        <li>{{ $code }}</li>
                                    @endforeach
                                </ul>
                            </div>
                            
                            <button class="btn btn-danger">Disable</button>
                        @else
                            <button class="btn btn-primary">Enable</button>
                        @endif
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
two-factor-authentication
two-factor-authentication_enabled
two-factor-authentication_disabled

パスワード再入力画面の作成

@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">{{ __('Confirm Password') }}</div>

                <div class="card-body">
                    {{ __('Please confirm your password before continuing.') }}

                    <form method="POST" action="{{ route('password.confirm') }}">
                        @csrf

                        <div class="row mb-3">
                            <label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="current-password">

                                @error('password')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Confirm Password') }}
                                </button>

                                @if (Route::has('password.request'))
                                    <a class="btn btn-link" href="{{ route('password.request') }}">
                                        {{ __('Forgot Your Password?') }}
                                    </a>
                                @endif
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
two-factor-authentication_confirm_password

2要素認証コード入力画面の作成

@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">{{ __('Two factor Challenge') }}</div>

                <div class="card-body">
                    {{ __('Please enter your authentication code to login.') }}

                    <form method="POST" action="{{ route('two-factor.login') }}">
                        @csrf

                        <div class="row mb-3">
                            <label for="code" class="col-md-4 col-form-label text-md-end">{{ __('Code') }}</label>

                            <div class="col-md-6">
                                <input id="code" type="code" class="form-control @error('code') is-invalid @enderror" name="code" required autocomplete="current-code">

                                @error('code')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Submit') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>

            <div class="card mt-3">
                <div class="card-header">{{ __('Two factor Recovery Code') }}</div>

                <div class="card-body">
                    {{ __('Please enter your authentication code to login.') }}

                    <form method="POST" action="{{ route('two-factor.login') }}">
                        @csrf

                        <div class="row mb-3">
                            <label for="recovery_code" class="col-md-4 col-form-label text-md-end">{{ __('Reocvery Code') }}</label>

                            <div class="col-md-6">
                                <input id="recovery_code" type="recovery_code" class="form-control @error('recovery_code') is-invalid @enderror" name="recovery_code" required autocomplete="current-recovery_code">

                                @error('code')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Submit') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>

        </div>
    </div>
</div>
@endsection
two-factor-authentication_login