Laravel Fortify + SanctumでSPA認証

# はじめに

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

* **[Laravel Fortify](https://readouble.com/laravel/10.x/ja/fortify.html)**
フロントエンドに依存しない認証バックエンドの実装が可能となる。
ログイン、ユーザ登録、パスワードのリセット、メールの検証など、認証機能を実装するために必要なルートとコントローラを作成してくれる。

* **[Laravel Sanctum](https://readouble.com/laravel/10.x/ja/sanctum.html)**
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` コマンドも併せて実行します。
```bash
composer require laravel/fortify
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
php artisan migrate
```

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

本記事では独自の view を作成するため、以下の設定を変更して不要なルートを除外します。
```diff_php:config\fortify.php
-  'views' => true,
+  'views' => false,
```

Fortify のルートをわかりやすく区別するため、prefix を変更します。
```diff_php:config\fortify.php
-  'prefix' => '',
+  'prefix' => 'fortify',
```

SPA認証で用いるため、ルートミドルウェアを `web` から `api` に変更します。
```diff_php:config\fortify.php
-  'middleware' => ['web'],
+  'middleware' => ['api'],
```

Fortify には様々な認証機能が用意されていますが、今回は最小限の「ユーザ登録」のみ有効にします。
```diff_php: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` も変更します。

```diff_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` を有効にします。

```diff_php: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 がリクエストを行うドメインを設定する必要があるため、`.env` に `SANCTUM_STATEFUL_DOMAINS` の設定を追記します。

```:.env
SANCTUM_STATEFUL_DOMAINS=localhost:8000
```

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

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

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

```diff_javascript: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` を新規作成します。

```html: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` を新規作成します。

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

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

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

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

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

https://reffect.co.jp/laravel/laravel9_vite

## 4. Vue Router のインストール

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

```bash
npm install vue-router@4
```

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

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

メインとなる `App.vue` に `<router-view>` の記述を追加します。
```diff_vue:resources\js\App.vue
<template>
+ <router-view />
</template>
```

Vue Router のインスタンスを作成する `index.js` を新規作成します。
```javascript: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` に以下の記述を追加します。
```diff_javascript: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 のルートを修正します。
```diff_php:routes\web.php
- Route::get('/', function () {
-   return view('welcome');
+ Route::get('/{path?}', function () {
+   return view('app');
});
```

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

```bash
npm run build
php artisan serve
```

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

https://qiita.com/riiiii/items/577a3e3ae28eebf5517c

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

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

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

Vue ファイルにスタイルを適用するため、`tailwind.config.js` を変更します。
```diff_javascript:tailwind.config.js
export default {
- content: [],
+ content: ['./resources/**/*.{vue,js,blade.php}'],
```

CSS ファイルに TailwindCSS の記述を追加します。
```css:resources\css\app.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

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

<details>
<summary>ユーザ登録画面</summary>

```vuejs: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>
```

</details>

<details>
<summary>ログイン画面</summary>

```vuejs: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>
```

</details>

<details>
<summary>マイページ画面</summary>

```vuejs: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>
```

</details>

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

```diff_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>
```

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

```diff_javascript: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')
    }
  ]
```

# 完成イメージ

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

```bash
npm run build
php artisan serve
```

## ユーザ登録画面
URL：http://localhost:8000/signup

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/3703b8f2-4ab6-262c-d8ee-c130e3099c12.png" width="400" />

## ログイン画面
URL：http://localhost:8000/login

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/0c674c62-6621-d9ef-f71f-9637758cb214.png" width="400" />

## マイページ画面
URL：http://localhost:8000/mypage

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/4a8af369-c9e0-07b5-9e92-b53ca0faa402.png" width="400" />

# GitHub

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

https://github.com/falya128/laravel-auth-spa