Laravel Breeze & Reactで認証機能を作る

はじめに

モダンなウェブアプリケーション開発において、セキュアな認証システムの実装は必須の要件です。多くの開発者が自前で認証システムを構築しようとすると、セキュリティの問題やバグの混入リスクが高まります。Laravel Breezeは、このような問題を解決するために、すぐに使える認証システムを提供しています。

特に、Laravel BreezeとReactを組み合わせることで、APIベースの認証システムを持つSPA(Single Page Application)を素早く構築できます。この記事では、Laravel BreezeとReactを使用して、セキュアで使いやすい認証機能を実装する方法を解説します。

Laravel Breezeとは

Laravel Breezeは、Laravelが公式に提供する軽量な認証スカフォールディングです。認証に必要な基本機能(ログイン、登録、パスワードリセット、メール認証など)を簡単に実装できます。さらに、フロントエンドにはInertia.js(Vue.js/React)またはLivewireを選択でき、APIベースのSPAも構築可能です。

開発環境の準備

まずは必要なツールをインストールしましょう。

必要なもの

  • PHP 8.1以上
  • Composer
  • Node.js & npm
  • Laravel CLI

Laravel Breezeのインストールと設定

1. 新しいLaravelプロジェクトの作成

まずは新しいLaravelプロジェクトを作成します。

composer create-project laravel/laravel laravel-breeze-react-auth
cd laravel-breeze-react-auth

2. データベースの設定

.envファイルを編集して、データベース接続情報を設定します。

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_breeze_react
DB_USERNAME=root
DB_PASSWORD=

3. Laravel Breezeのインストール

Composerを使ってLaravel Breezeをインストールします。

composer require laravel/breeze --dev

4. BreezeのAPIとReactスカフォールディングのインストール

以下のコマンドを実行して、API用のBreezeとReactスカフォールディングをインストールします。

php artisan breeze:install react

このコマンドを実行すると以下のファイルが生成されます:

  • APIルート設定
  • コントローラ
  • Reactコンポーネント
  • 認証関連のビュー

5. データベースのマイグレーション

認証に必要なテーブルを作成するために、マイグレーションを実行します。

php artisan migrate

6. フロントエンドの依存関係のインストールとビルド

npmを使って、フロントエンドの依存関係をインストールし、ビルドします。

npm install
npm run dev

これで基本的なセットアップは完了です。http://localhost:8000にアクセスすると、React製のフロントエンドが表示されます。

Laravel Breezeの構造を理解する

バックエンド(Laravel)側の構造

Laravel Breezeをインストールすると、以下のファイルが生成されます:

コントローラ

認証関連のコントローラがapp/Http/Controllers/Authディレクトリに生成されます:

  • RegisteredUserController.php – ユーザー登録処理
  • AuthenticatedSessionController.php – ログイン・ログアウト処理
  • PasswordResetController.php – パスワードリセット処理
  • EmailVerificationController.php – メール確認処理
  • など

ルート設定

APIルートはroutes/auth.phpに定義されています:

Route::post('/register', [RegisteredUserController::class, 'store'])
    ->middleware('guest')
    ->name('register');

Route::post('/login', [AuthenticatedSessionController::class, 'store'])
    ->middleware('guest')
    ->name('login');

Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
    ->middleware('guest')
    ->name('password.email');

// その他の認証関連ルート

これらのルートはroutes/web.phpで読み込まれます。

ミドルウェア

認証状態を確認するミドルウェアも用意されています:

  • RedirectIfAuthenticated – 認証済みユーザーをリダイレクト
  • Authenticate – 未認証ユーザーをリダイレクト

フロントエンド(React)側の構造

Reactコンポーネントはresources/jsディレクトリに生成されます:

コンポーネント

  • Pages/Auth/Login.jsx – ログインフォーム
  • Pages/Auth/Register.jsx – 登録フォーム
  • Pages/Auth/ForgotPassword.jsx – パスワードリセットフォーム
  • Pages/Dashboard.jsx – ダッシュボード(認証後のページ)
  • など

レイアウト

  • Layouts/AuthenticatedLayout.jsx – 認証済みユーザー用レイアウト
  • Layouts/GuestLayout.jsx – 未認証ユーザー用レイアウト

ユーティリティ

  • hooks/auth.js – 認証関連のReactフック
  • bootstrap.js – Axiosの設定

認証機能の動作の流れ

1. ユーザー登録の流れ

  1. ユーザーが登録フォームに情報を入力
  2. フロントエンドのバリデーション
  3. APIリクエスト(/registerエンドポイント)
  4. バックエンドでのバリデーション
  5. ユーザーの作成とDBへの保存
  6. 認証の確立とレスポンス
  7. フロントエンドでのリダイレクト(通常はダッシュボードへ)

2. ログインの流れ

  1. ユーザーがログインフォームに情報を入力
  2. APIリクエスト(/loginエンドポイント)
  3. 認証の試行
  4. セッションの作成
  5. レスポンス
  6. フロントエンドでのリダイレクト

3. API認証の仕組み

Laravel BreezeのReactスタックでは、SanctumというLaravelの認証パッケージを使用しています。SanctumはAPI認証のために以下の機能を提供します:

  • トークンベースの認証
  • SPAのための認証(CSRF保護、セッションクッキー)
  • ルート保護

カスタマイズ例

実際のアプリケーションでは、基本的な認証機能をカスタマイズする必要があることがよくあります。以下に、よく行われるカスタマイズの例をいくつか示します。

1. ユーザーモデルの拡張

追加のユーザー情報(プロフィール、権限など)を保存したい場合、Userモデルとデータベースのスキーマを拡張します。

// マイグレーションファイルの作成
php artisan make:migration add_profile_fields_to_users_table

// マイグレーションファイルの編集
public function up()
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('avatar')->nullable();
        $table->text('bio')->nullable();
        $table->string('role')->default('user');
    });
}

Userモデルも更新します:

// app/Models/User.php
protected $fillable = [
    'name',
    'email',
    'password',
    'avatar',
    'bio',
    'role',
];

2. 登録フォームのカスタマイズ

追加のフィールドを登録フォームに追加する例:

// resources/js/Pages/Auth/Register.jsx
import { useEffect, useState } from 'react';
import GuestLayout from '@/Layouts/GuestLayout';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput';
import { Head, Link, useForm } from '@inertiajs/react';

export default function Register() {
    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
        email: '',
        password: '',
        password_confirmation: '',
        bio: '', // 追加フィールド
    });

    useEffect(() => {
        return () => {
            reset('password', 'password_confirmation');
        };
    }, []);

    const submit = (e) => {
        e.preventDefault();
        post(route('register'));
    };

    return (
        <GuestLayout>
            <Head title="Register" />

            <form onSubmit={submit}>
                <div>
                    <InputLabel htmlFor="name" value="Name" />
                    <TextInput
                        id="name"
                        name="name"
                        value={data.name}
                        className="mt-1 block w-full"
                        autoComplete="name"
                        isFocused={true}
                        onChange={(e) => setData('name', e.target.value)}
                        required
                    />
                    <InputError message={errors.name} className="mt-2" />
                </div>

                <div className="mt-4">
                    <InputLabel htmlFor="email" value="Email" />
                    <TextInput
                        id="email"
                        type="email"
                        name="email"
                        value={data.email}
                        className="mt-1 block w-full"
                        autoComplete="username"
                        onChange={(e) => setData('email', e.target.value)}
                        required
                    />
                    <InputError message={errors.email} className="mt-2" />
                </div>

                {/* 追加フィールド */}
                <div className="mt-4">
                    <InputLabel htmlFor="bio" value="Bio" />
                    <textarea
                        id="bio"
                        name="bio"
                        value={data.bio}
                        className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
                        onChange={(e) => setData('bio', e.target.value)}
                    />
                    <InputError message={errors.bio} className="mt-2" />
                </div>

                <div className="mt-4">
                    <InputLabel htmlFor="password" value="Password" />
                    <TextInput
                        id="password"
                        type="password"
                        name="password"
                        value={data.password}
                        className="mt-1 block w-full"
                        autoComplete="new-password"
                        onChange={(e) => setData('password', e.target.value)}
                        required
                    />
                    <InputError message={errors.password} className="mt-2" />
                </div>

                <div className="mt-4">
                    <InputLabel htmlFor="password_confirmation" value="Confirm Password" />
                    <TextInput
                        id="password_confirmation"
                        type="password"
                        name="password_confirmation"
                        value={data.password_confirmation}
                        className="mt-1 block w-full"
                        autoComplete="new-password"
                        onChange={(e) => setData('password_confirmation', e.target.value)}
                        required
                    />
                    <InputError message={errors.password_confirmation} className="mt-2" />
                </div>

                <div className="flex items-center justify-end mt-4">
                    <Link
                        href={route('login')}
                        className="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                    >
                        Already registered?
                    </Link>

                    <PrimaryButton className="ml-4" disabled={processing}>
                        Register
                    </PrimaryButton>
                </div>
            </form>
        </GuestLayout>
    );
}

コントローラ側も更新する必要があります:

// app/Http/Controllers/Auth/RegisteredUserController.php
protected function create(array $input)
{
    return User::create([
        'name' => $input['name'],
        'email' => $input['email'],
        'password' => Hash::make($input['password']),
        'bio' => $input['bio'] ?? null, // 追加フィールド
    ]);
}

3. 権限に基づくアクセス制御

ユーザーの役割に基づいてアクセスを制限する例:

// app/Providers/AuthServiceProvider.php
public function boot(): void
{
    Gate::define('access-admin', function (User $user) {
        return $user->role === 'admin';
    });
}
// routes/web.php
Route::middleware(['auth', 'can:access-admin'])->group(function () {
    Route::get('/admin', [AdminController::class, 'index'])->name('admin.dashboard');
    // 他の管理者ルート
});

React側では、権限に基づいて表示/非表示を切り替えることができます:

// resources/js/Layouts/AuthenticatedLayout.jsx
{user.role === 'admin' && (
    <NavLink href={route('admin.dashboard')} active={route().current('admin.dashboard')}>
        Admin Dashboard
    </NavLink>
)}

ソーシャルログインの追加

Laravel Socialiteを使用して、ソーシャルログイン(Google、GitHub、Facebookなど)を追加することもできます。

1. Socialiteのインストール

composer require laravel/socialite

2. .envの設定

GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GITHUB_REDIRECT_URI=http://localhost:8000/auth/github/callback

3. config/services.phpの設定

'github' => [
    'client_id' => env('GITHUB_CLIENT_ID'),
    'client_secret' => env('GITHUB_CLIENT_SECRET'),
    'redirect' => env('GITHUB_REDIRECT_URI'),
],

4. ルートの追加

// routes/web.php
use App\Http\Controllers\Auth\SocialiteController;

Route::get('/auth/github', [SocialiteController::class, 'redirectToGithub'])
    ->middleware('guest')
    ->name('auth.github');

Route::get('/auth/github/callback', [SocialiteController::class, 'handleGithubCallback'])
    ->middleware('guest');

5. コントローラの作成

// app/Http/Controllers/Auth/SocialiteController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;

class SocialiteController extends Controller
{
    public function redirectToGithub()
    {
        return Socialite::driver('github')->redirect();
    }

    public function handleGithubCallback()
    {
        try {
            $githubUser = Socialite::driver('github')->user();

            $user = User::updateOrCreate(
                ['email' => $githubUser->getEmail()],
                [
                    'name' => $githubUser->getName() ?? $githubUser->getNickname(),
                    'password' => bcrypt(str_random(16)),
                    'github_id' => $githubUser->getId(),
                    'github_token' => $githubUser->token,
                ]
            );

            Auth::login($user);

            return redirect()->intended('/dashboard');
        } catch (\Exception $e) {
            return redirect('/login')->withErrors(['error' => 'GitHub認証に失敗しました。']);
        }
    }
}

6. フロントエンド側の実装

// resources/js/Pages/Auth/Login.jsx(一部抜粋)
<div className="mt-4">
    <a
        href={route('auth.github')}
        className="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150"
    >
        <svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
            {/* GitHub SVGアイコン */}
        </svg>
        Sign in with GitHub
    </a>
</div>

デプロイの注意点

Laravelアプリケーションとともにフロントエンドをデプロイする際の注意点:

  1. 本番ビルド: フロントエンドコードの本番ビルドを行う npm run build
  2. 環境変数: 本番環境用の環境変数を設定する APP_ENV=production APP_DEBUG=false SESSION_SECURE_COOKIE=true SANCTUM_STATEFUL_DOMAINS=yourdomain.com
  3. CORS設定: APIとフロントエンドのドメインが異なる場合、CORS設定を適切に行う // config/cors.php 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_origins' => ['https://your-frontend-domain.com'], 'allowed_methods' => ['*'], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, 'supports_credentials' => true,
  4. セキュリティヘッダ: 適切なセキュリティヘッダを設定する(CSP、XSS対策など)

まとめ

Laravel Breezeを使用することで、セキュアな認証システムを迅速に構築できます。Reactと組み合わせることで、モダンで使いやすいSPAを開発することが可能です。この記事で紹介した基本的な設定とカスタマイズ例を参考に、あなたのアプリケーションに合った認証システムを構築してみてください。

Laravel Breezeは、認証の実装を簡素化しますが、それでもセキュリティのベストプラクティスに従って実装することが重要です。特に本番環境では、HTTPSの使用、適切なCORS設定、セキュリティヘッダの設定などに注意しましょう。

これで、Laravel BreezeとReactを使った認証システムの実装と、その基本的なカスタマイズ方法について理解できたと思います。あなたのプロジェクトで活用してください!

コメント

タイトルとURLをコピーしました