Laravel+Nuxt 環境で sanctum の SPA(クッキー)認証する

#＃ 概要

この記事は、Laravel+Nuxtでsanctum認証を実装時の自分用の備忘録になります。

## 環境

- mac M1
- Laravel:8.63.0
- Laravel Sanctum:2.11
- Nuxt:2.15.8

## インストール(Laravel)

作業ディレクトリを適当な場所に作成する。

```
mkdir laravel-nuxt
```

Laravel プロジェクトの作成
特にバージョンにこだわりなかったので、最新の8系をインストール。

```
composer create-project laravel/laravel
```

sanctum のインストール

```
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
```

database の準備
手軽に確認できるためsqliteを使用しました。
環境に合わせて適宜修正してください。

```
touch database/database.sqlite
```

.env ファイル修正

```.env
DB_CONNECTION=sqlite // 修正
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
```

ログイン実装で使用するUserテーブルを作成したいため、migrationを実行します。

```
php artisan migrate
```

## Cookie 認証

Sancatum のミドルウェアを`app/Http/Kernel.php`の`api`グループに追加します。
これにより Laravel のセッションクッキーを使用して認証することができます。

```php:app/Http/Kernel.php
class Kernel extends HttpKernel
{
    protected $middlewareGroups = [
        'api' => [
            \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // 追記
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];
}
```

## CORS とクッキー

別のサブドメインで実行する場合`Access-Control-Allow-Credentials`ヘッダを`true`で返す必要があるため、以下を設定します。

```php:config/cors.php
    'supports_credentials' => true,
```

## SPA 認証の実装

SPA 認証するには CSRF 保護を初期化し、ログイン処理をする必要があります。
CSRF 保護を初期化するには`/sanctum/csrf-cookie`エンドポイントにリクエストを送信する必要があるので注意しましょう。

```java
axios.get('/sanctum/csrf-cookie').then(response => {
    // ログイン…
});
```

またログインやログアウト処理は Laravel の公式曰く'web'ルートに作成する必要がある。
[Laravel 8.x Laravel Sanctum](https://readouble.com/laravel/8.x/ja/sanctum.html#:~:text=domain%27%20%3D%3E%20%27.domain.com%27%2C-,%E8%AA%8D%E8%A8%BC,-CSRF%E4%BF%9D%E8%AD%B7)

ログインコントローラーの作成を行う。

```
php artisan make:controller LoginController
```

ルーティングの設定を行う。

```php:routes/web.php
<?php

use App\Http\Controllers\LoginController;

Route::post('/login', [LoginController::class, 'login']);
Route::post('/logout', [LoginController::class, 'logout']);
```

`config/cors.php`に`login`と`logout`のパスを追加。

```php:config/cors.php
    'paths' => [
        'api/*',
        'login', // 追加
        'logout', // 追加
        'sanctum/csrf-cookie',
    ],
```

`LoginController`の実装をする。公式の`ユーザーを手動で認証する`の[コード](https://readouble.com/laravel/8.x/ja/authentication.html#:~:text=%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84%E3%80%82-,%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%82%92%E6%89%8B%E5%8B%95%E3%81%A7%E8%AA%8D%E8%A8%BC%E3%81%99%E3%82%8B,-Laravel%E3%81%AE)の return を少し変更します。

```php:LoginController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use SebastianBergmann\Environment\Console;

class LoginController extends Controller
{
    /**
     * Handle an authentication attempt.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();

            return response()->json(Auth::user());
        }

        return response()->json(['message' => 'ログインに失敗しました'], 401);
    }

    public function logout(Request $request)
    {
        Auth::logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return response()->json(['message' => 'ログアウトしました']);
    }
}
```

また`api.php`のコードを一部修正します。

```php:routes/api.php
use Illuminate\Support\Facades\Auth;

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return Auth::user(); //修正箇所
});
```

クッキー認証の実装は完了です。次にテスト用のユーザを作成します。

```
$ php artisan tinker

App\Models\User::factory()->create(['email' => 'hoge@example.com']);
exit
```

Laravel8 系のデフォルトパスワードは`password`です。
Laravel の実装はこれで終了です。
次に Nuxt 側の実装を行っていきます。

## インストール(Nuxt)

Nuxt プロジェクトの作成

```
yarn create nuxt-app nuxt
```

初期設定は`SSR`設定と`axios`モジュールを選択します。あとはお好みで選択してください。

## axios で CORS の設定

`nuxt.config.js`に以下を追記します。
これによって`axios`の`withCredentials`オプションを`true`にします。

```js:nuxt.config.js
  axios: {
    baseURL: 'http://localhost:8000',
    credentials: true,
  },
```

## ログインした管理ユーザーを保存する Store を作成する。

ログインユーザーを管理するため、`store/auth.js`を作成します。
ログインの処理をする前に`/sanctum/csrf-cookie`にアクセスし,CSRF 保護を初期化する必要がありますので注意してください。

```js:store/auth.js
export const state = () => ({
  authUser: null,
})

export const mutations = {
  setAuthUser(state, authUser) {
    state.authUser = authUser
  },
}

export const actions = {
  async login({ commit }, { email, password }) {
    await this.$axios.get('/sanctum/csrf-cookie').then(async (res) => {
      const response = await this.$axios
        .$post('/login', { email, password })
        .catch((err) => {
          console.log(err)
        })
      commit('setAuthUser', response)
    })
  },
  async logout({ commit }) {
    await this.$axios
      .$post('/logout')
      .then((res) => {
        console.log(res)
      })
      .catch((err) => {
        console.log(err)
      })
    commit('setAuthUser', null)
  },
}
```

## API からログイン中の管理者ユーザを取得するようにする

Store のデータはリロードすると消えてしまうため、リロードする度にログインする必要があります。
そのため画面表示前にログイン中のユーザー情報を取得して Store にセットする処理が必要になります。
SSR から Cookie を取得するために`cookieparser`モジュールを追加する。

```
yarn add cookieparser
```

`store/index.js`を追加し、以下を記述する。

```js:store/index.js
const cookieparser = process.server ? require('cookieparser') : undefined

export const actions = {
  async nuxtServerInit({ commit }, { req, app }) {
    cookieparser.parse(req.headers.cookie)

    await app.$axios
      .$get('/api/user')
      .then((authUser) => {
        commit('auth/setAuthUser', authUser)
      })
      .catch((err) => {
        console.log(err)
        commit('auth/setAuthUser', null)
      })
  },
}
```

nuxtServerInit は SSR 時に実行される処理で、
Cookie が有効な間はログイン中の管理者ユーザーの情報が毎回セットされるようになります。

## ログイン中かをチェックして画面遷移させるミドルウェアを作成する

`middleware`配下に`unAuthenticated.js`を作成します。
これにより Store に `state.auth.authUser` に設定されていれば、管理画面に遷移させます。

```js:unAuthenticated.js
export default function ({ store, redirect }) {
  if (store.state.auth.authUser) {
    return redirect('/admin')
  }
}
```

`middleware` 配下に`authenticated.js`を作成し
これにより Store に`state.auth.authUser`に設定されていなければ、ログイン画面に遷移します。

```js:authenticated.js
export default function ({ store, redirect }) {
  if (!store.state.auth.authUser) {
    return redirect('/admin/login')
  }
}
```

## ログイン後の画面を作成する

`pages/admin/index.vue`ファイルを作成する。

```js:pages/admin/index.vue
<template>
  <section>
    <h1>管理画面へようこそ</h1>
    <button @click="logout">ログアウト</button>
  </section>
</template>

<script>
export default {
  middleware: 'authenticated',
  methods: {
    async logout() {
      await this.$store.dispatch('auth/logout', null)
      this.$router.push('/admin/login')
    },
  },
}
</script>
```

## ログイン画面を作成する

`pages/admin/login.vue`を作成する。

```js:pages/admin/login.vue
<template>
  <section>
    <h1>ログイン</h1>
    <form @submit.prevent="submit">
      <div>
        <label for="email">email</label>
        <input type="text" id="email" v-model="email" />
      </div>
      <div>
        <label for="password">password</label>
        <input type="password" id="password" v-model="password" />
      </div>
      <button type="submit">login</button>
    </form>
  </section>
</template>

<script>
export default {
  middleware: 'unAuthenticated',
  data() {
    return {
      email: '',
      password: '',
    }
  },
  methods: {
    async submit() {
      await this.$store.dispatch('auth/login', {
        email: this.email,
        password: this.password,
      })
      this.$router.push('/admin')
    },
  },
}
</script>
```
## 動作確認

アプリを起動して、動作確認します。
まずLaravel(API)サーバーを起動します。

```
php artisan serve
```

次に Nuxt アプリを起動する。

```
yarn run dev
```

起動が完了後、http://localhost:3000/admin にアクセスします。
下記のような画面が表示されると思います。
![スクリーンショット 2021-10-15 8.36.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317229/2fa331d2-e68d-d61c-243b-570a1e3bc335.png)
      
次に事前に登録して管理者ユーザーのメールアドレスとパスワードを入力してログインします。
ログインが成功し、/adminにリダイレクトされ下記の画面が表示されればOKです。

![スクリーンショット 2021-10-15 8.40.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317229/b32aa26c-8211-6441-299e-aad73ce63510.png)

またログイン後にリロードしてもStoreにログインユーザが設定され管理画面から遷移されないことを確認します。
ログアウトボタンを押下し、ログイン画面まで遷移されればOKです。