2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Laravel Fortify + SanctumでSPA認証

Posted at

はじめに

Laravel の認証機能を作成するために用意されたパッケージ Laravel Fortify と Laravel Sanctum にはそれぞれ次のような特徴があり、併用が可能です。

  • Laravel Fortify
    フロントエンドに依存しない認証バックエンドの実装が可能となる。
    ログイン、ユーザ登録、パスワードのリセット、メールの検証など、認証機能を実装するために必要なルートとコントローラを作成してくれる。

  • Laravel Sanctum
    APIトークンの管理とセッションCookieまたはトークンを使用したユーザの認証機能(SPA認証)の実装が可能となる。

そこで本記事では、この2つを併用して SPA のバックエンド認証を作成する実装例を示していきます。

技術構成

バックエンド

  • Laravel 10.x
    • Laravel Fortify
    • Laravel Sanctum

フロントエンド

  • Vue.js 3.3
    • Vue Router 4
  • TailwindCSS

実装手順

前提条件

  • Laravel 10.x をインストール済み
  • データベースの接続情報を .env に設定済み

1. Laravel Fortify のインストール

Fortify のパッケージをインストールして、vendor:publish コマンドで必要なファイルを作成します。
また、データベースのテーブルを作成するため、migrate コマンドも併せて実行します。

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

コマンドの実行が終わったら、SPAの実装に合わせて config\fortify.php を変更します。

本記事では独自の view を作成するため、以下の設定を変更して不要なルートを除外します。

config\fortify.php
-  'views' => true,
+  'views' => false,

Fortify のルートをわかりやすく区別するため、prefix を変更します。

config\fortify.php
-  'prefix' => '',
+  'prefix' => 'fortify',

SPA認証で用いるため、ルートミドルウェアを web から api に変更します。

config\fortify.php
-  'middleware' => ['web'],
+  'middleware' => ['api'],

Fortify には様々な認証機能が用意されていますが、今回は最小限の「ユーザ登録」のみ有効にします。

config\fortify.php
    'features' => [
        Features::registration(),
-       Features::resetPasswords(),
+       // Features::resetPasswords(),
        // Features::emailVerification(),
-       Features::updateProfileInformation(),
-       Features::updatePasswords(),
-       Features::twoFactorAuthentication([
-           'confirm' => true,
-           'confirmPassword' => true,
-           // 'window' => 0,
-       ]),
+       // Features::updateProfileInformation(),
+       // Features::updatePasswords(),
+       // Features::twoFactorAuthentication([
+       //     'confirm' => true,
+       //     'confirmPassword' => true,
+       //     // 'window' => 0,
+       // ]),
    ],

また、作成した FortifyServiceProvider をプロバイダとして登録しておく必要があるので、app.php も変更します。

config\app.php
    'providers' => ServiceProvider::defaultProviders()->merge([
        /*
         * Package Service Providers...
         */
+       App\Providers\FortifyServiceProvider::class,

2. Laravel Sanctum の初期設定

Laravel 10.x では標準で Sanctum がインストールされているため、必要な設定のみ行っていきます。

SPA認証で Sanctum を有効にするため、api ミドルウェアグループのEnsureFrontendRequestsAreStateful を有効にします。

app\Http\Kernel.php
    protected $middlewareGroups = [
        'api' => [
-           // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
+           \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

SPA がリクエストを行うドメインを設定する必要があるため、.envSANCTUM_STATEFUL_DOMAINS の設定を追記します。

.env
SANCTUM_STATEFUL_DOMAINS=localhost:8000

3. Vue.js のインストール

Vite 環境で Vue を動作させるため、@vitejs/plugin-vue をインストールします。

npm install @vitejs/plugin-vue --save-dev

vite.config.js に Vue の設定を追記します。併せて、Base URL も変更しておきます。

vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
+ import vue from '@vitejs/plugin-vue';

export default defineConfig({
+   base: '/',
    plugins: [
+       vue(),
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

初期で作成される welcome.blade.php を削除して、resources\views\app.blade.php を新規作成します。

resources\views\app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>{{ config('app.name') }}</title>
  @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

<body>
  <div id="app"></div>
</body>

</html>

メインとなる Vue ファイル resources\js\App.vue を新規作成します。

resources\js\App.vue
<template>
</template>

resources\js\app.jscreateApp() の記述を追加して、Vueアプリケーションのインスタンスを作成します。

import './bootstrap'
+ import App from './App.vue'
+ import { createApp } from 'vue'

+ const app = createApp(App)
+ app.mount('#app')

※ Vue.js のインストール手順はこちらの記事を参考にさせて頂きました。

4. Vue Router のインストール

SPAのルートを担う役割として Vue Router を用いるため、vue-router をインストールします。

npm install vue-router@4

ログイン画面の Vue ファイル resources\js\views\Login.vue を新規作成します。

resources\js\views\Login.vue
<template>
  <h1>ログイン画面</h1>
</template>

メインとなる App.vue<router-view> の記述を追加します。

resources\js\App.vue
<template>
+ <router-view />
</template>

Vue Router のインスタンスを作成する index.js を新規作成します。

resources\js\router\index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('../views/Login.vue')
    }
  ]
})
export default router

作成したインスタンスを Vue に読み込ませるため、app.js に以下の記述を追加します。

resources\js\app.js
import './bootstrap'

import App from './App.vue'
import { createApp } from 'vue'
+ import router from './router'

const app = createApp(App)
+ app.use(router)
app.mount('#app')

Vue Router でルートを制御するため、Laravel のルートを修正します。

routes\web.php
- Route::get('/', function () {
-   return view('welcome');
+ Route::get('/{path?}', function () {
+   return view('app');
});

ここまで正しく実装できているか、一度確認をしてみます。ビルド & 開発用サーバを立ち上げて、http://localhost:8000/login にアクセスします。"ログイン画面" という文字が表示されればOKです。

npm run build
php artisan serve

※ Vue Router のインストール手順はこちらの記事を参考にさせて頂きました。

5. ユーザ登録、ログイン、マイページ画面の作成

まず最初に、見た目を整えるための CSS フレームワークとして TailwindCSS をインストールします。

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Vue ファイルにスタイルを適用するため、tailwind.config.js を変更します。

tailwind.config.js
export default {
- content: [],
+ content: ['./resources/**/*.{vue,js,blade.php}'],

CSS ファイルに TailwindCSS の記述を追加します。

resources\css\app.css
@tailwind base;
@tailwind components;
@tailwind utilities;

次に、各画面の Vue ファイルを作成します。

ユーザ登録画面
resources\js\views\Signup.vue
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'

const router = useRouter()
const form = ref({
  email: '',
  name: '',
  password: '',
  password_confirmation: ''
})
const errors = ref([])

const signup = async () => {
  await axios.get('/sanctum/csrf-cookie')
  await axios
    .post('/fortify/register', form.value)
    .then(() => {
      router.push('/login')
    })
    .catch((reason) => {
      errors.value = reason?.response?.data?.errors ?? {}
    })
}
</script>

<template>
  <div class="flex min-h-full flex-1 flex-col justify-center px-6 py-8 lg:px-8">
    <div class="sm:mx-auto sm:w-full sm:max-w-sm">
      <h2 class="mt-6 text-center text-3xl font-bold leading-9 tracking-tight text-gray-900">
        Create your account
      </h2>
    </div>

    <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
      <form class="space-y-6" @submit.prevent="signup()">
        <div>
          <label for="email" class="block text-sm font-medium leading-6 text-gray-900"
            >Email address</label
          >
          <div class="mt-2">
            <input
              v-model="form.email"
              id="email"
              name="email"
              type="email"
              autocomplete="email"
              required
              class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-gray-600 sm:text-sm sm:leading-6"
            />
            <template v-if="errors.email">
              <div v-for="error in errors.email" class="text-sm text-red-700 m-1" role="alert">
                {{ error }}
              </div>
            </template>
          </div>
        </div>

        <div>
          <label for="username" class="block text-sm font-medium leading-6 text-gray-900"
            >Username</label
          >
          <div class="mt-2">
            <input
              v-model="form.name"
              id="username"
              name="username"
              type="text"
              autocomplete="username"
              required
              class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
            />
            <template v-if="errors.name">
              <div v-for="error in errors.name" class="text-sm text-red-700 m-1" role="alert">
                {{ error }}
              </div>
            </template>
          </div>
        </div>

        <div>
          <div class="flex items-center justify-between">
            <label for="password" class="block text-sm font-medium leading-6 text-gray-900"
              >Password</label
            >
          </div>
          <div class="mt-2">
            <input
              v-model="form.password"
              id="password"
              name="password"
              type="password"
              required
              class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
            />
            <template v-if="errors.password">
              <div v-for="error in errors.password" class="text-sm text-red-700 m-1" role="alert">
                {{ error }}
              </div>
            </template>
          </div>
        </div>

        <div>
          <div class="flex items-center justify-between">
            <label for="password" class="block text-sm font-medium leading-6 text-gray-900"
              >Password for confirmation</label
            >
          </div>
          <div class="mt-2">
            <input
              v-model="form.password_confirmation"
              id="password_confirmation"
              name="password_confirmation"
              type="password"
              required
              class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
            />
          </div>
        </div>

        <div>
          <button
            type="submit"
            class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          >
            Sign up
          </button>
        </div>
      </form>

      <p class="mt-10 text-center text-sm text-gray-500">
        Already have an account?
        <router-link
          to="/login"
          class="font-semibold leading-6 text-indigo-600 hover:text-indigo-500"
          >Log in</router-link
        >
      </p>
    </div>
  </div>
</template>
ログイン画面
resources\js\views\Login.vue
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'

const router = useRouter()
const form = ref({
  email: '',
  password: ''
})
const errors = ref([])

const login = async () => {
  await axios.get('/sanctum/csrf-cookie')
  await axios
    .post('/fortify/login', form.value)
    .then(() => {
      router.push('/mypage')
    })
    .catch((reason) => {
      errors.value = reason?.response?.data?.errors ?? {}
    })
}
</script>

<template>
  <div class="flex min-h-full flex-1 flex-col justify-center px-6 py-8 lg:px-8">
    <div class="sm:mx-auto sm:w-full sm:max-w-sm">
      <h2 class="mt-6 text-center text-3xl font-bold leading-9 tracking-tight text-gray-900">
        Sign in to your account
      </h2>
    </div>

    <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
      <form class="space-y-6" @submit.prevent="login()">
        <div>
          <label for="email" class="block text-sm font-medium leading-6 text-gray-900"
            >Email address</label
          >
          <div class="mt-2">
            <input
              v-model="form.email"
              id="email"
              name="email"
              type="email"
              autocomplete="email"
              required
              class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
            />
            <template v-if="errors.email">
              <div v-for="error in errors.email" class="text-sm text-red-700 m-1" role="alert">
                {{ error }}
              </div>
            </template>
          </div>
        </div>

        <div>
          <div class="flex items-center justify-between">
            <label for="password" class="block text-sm font-medium leading-6 text-gray-900"
              >Password</label
            >
          </div>
          <div class="mt-2">
            <input
              v-model="form.password"
              id="password"
              name="password"
              type="password"
              autocomplete="current-password"
              required
              class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
            />
            <template v-if="errors.password">
              <div v-for="error in errors.password" class="text-sm text-red-700 m-1" role="alert">
                {{ error }}
              </div>
            </template>
          </div>
        </div>

        <div>
          <button
            type="submit"
            class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          >
            Log in
          </button>
        </div>
      </form>

      <p class="mt-10 text-center text-sm text-gray-500">
        Don't have an account?
        <router-link
          to="/signup"
          class="font-semibold leading-6 text-indigo-600 hover:text-indigo-500"
          >Sign up</router-link
        >
      </p>
    </div>
  </div>
</template>
マイページ画面
resources\js\views\Mypage.vue
<script setup>
import { useRouter } from 'vue-router'
import axios from 'axios'

const router = useRouter()

let name = ''
await axios
  .get('/api/user')
  .then((response) => {
    name = response?.data?.name
  })
  .catch((reason) => {
    if (reason?.response?.status === 401) {
      router.push('/login')
    }
  })

const logout = async () => {
  await axios.post('/fortify/logout')
  router.push('/login')
}
</script>

<template>
  <main class="grid min-h-full place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8">
    <div class="text-center">
      <h1 class="mt-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
        Welcome, {{ name }}
      </h1>
      <div class="mt-16 flex items-center justify-center">
        <button
          type="button"
          class="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          @click="logout"
        >
          Log out
        </button>
      </div>
    </div>
  </main>
</template>

また、マイページ画面において setup() 内に非同期処理を記述しているため、 <Suspense> を用いてレンダリングを制御する必要があります。そこで App.vue を変更します。

resources\js\App.vue
+ <script setup>
+ import { RouterView } from 'vue-router'
+ </script>
+ 
<template>
- <router-view />
+ <RouterView v-slot="{ Component }">
+   <Suspense>
+     <div>
+       <component :is="Component" />
+     </div>
+   </Suspense>
+ </RouterView>
</template>

最後に、新たに追加した画面のルートを追記します。

resources\js\router\index.js
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('../views/Login.vue')
+   },
+   {
+     path: '/mypage',
+     name: 'mypage',
+     component: () => import('../views/Mypage.vue')
+   },
+   {
+     path: '/signup',
+     name: 'signup',
+     component: () => import('../views/Signup.vue')
    }
  ]

完成イメージ

以下のコマンドでビルド & 開発用サーバを起動して確認します。

npm run build
php artisan serve

ユーザ登録画面

URL:http://localhost:8000/signup

ログイン画面

URL:http://localhost:8000/login

マイページ画面

URL:http://localhost:8000/mypage

GitHub

完成版の Laravel プロジェクトはこちら

2
4
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
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?