📘

Vue3とLaravelにおけるルート保護

2023/01/20に公開

Vue3とlaravelを使用してアプリ開発を行っています。
今回はVue3とlaravelを使用した完全SPAのアプリにおけるルート保護について説明していきます。

環境

  • Mac(Intel)
  • Vue3.2
  • Laravel8.75
  • Laravel Sanctum2.11
  • Docker
  • Vue-router
  • Vite

結論

結論から言いますと、以下の形になるかと思います。

  • ログイン情報自体はサーバー側のsessionにて管理する形にする
  • クライアント側は認証済みか必要になったときにサーバー側にリクエストを投げて認証済みかをチェックする
    • その結果(true/falseなど)をlocalStorageに保持する

ちなみに、どこに保持させるべきかの論争は以下の参考記事に書かれていますので、そちらを見ていただければと思います。

参考記事

前提

まず前提として、クライアント側のルート保護とサーバー側のルート保護があるかと思います。

  • クライアント側は、そのページにアクセスしたときにどのページは閲覧可能か
  • サーバー側はクライアント側から、リクエストが投げられたときにそのデータを保存するかなど

それではそれぞれ見ていきましょう!

サーバー側

サーバー側にはLaravelを使っておりますので、laravelの認証機能を使っていきます。
laravelでspaを使った開発を行う際は、Laravel Sanctumというものを使う形になりそうです。
Laravel8.6以降は標準で搭載されているので、今回はinstall手順などは省きます。

正直以下の記事がめちゃくちゃわかりやすいので、以下の参考記事を元に導入していだければと思います。
- Laravel Sanctum SPA認証の実装
- Laravel API SanctumでSPA認証する

ただ、少し違いはあると思いますので、一応自分はこういう感じになったよというのを書いていこうと思います。

Kernel.php内のapiの部分で、1番上がコメントアウトされていると思うので、そのコメントアウトを外します。
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::classの部分ですね。

Kernel.php
protected $middlewareGroups = [
    ....

    'api' => [
        \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
        'throttle:api',
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

そして、cors.php内を書き換えます

cors.php
'supports_credentials' => true,

そしてここをtrueに変えるとaxioswithCredentialsをtrueにしてあげる必要があります。
詳しくはクライアント側の方で、説明します。

さらに、envファイルの方を以下に変更す。
こうすることで、セッションidがクッキーによって管理されるようになります。

...
SESSION_DRIVER=cookie

さらに、自分は以下の形でmiddlewareを設定して、サーバー側のルート保護を行いました。
今回laravelはAPIとして使用しますので、api.phpにルーティングを記述するようにしてください。

api.php
Route::group(['middleware' => ['auth:sanctum']], function () {
    // メール認証は完了していなくても、ログイン認証が完了していないとアクセスできないルーティング

    Route::group(['middleware' => ['verified']], function () {
        // メール認証もログイン認証も完了していないとアクセスできないルーティング
    });
});

これで、一応大丈夫とは思いますが、一応laravel-sanctumが導入されているかチェックしてみてください。
以下のルーティングが存在していれば大丈夫かと思います。

$ php artisan route:list
 | GET|HEAD  | sanctum/csrf-cookie |  |Laravel\Sanctum\Http\Controllers\CsrfCookieController@show  | web

クライアント側

クライアント側のルート保護が結構大変でした。
ログイン認証をlocalStorageに保持してはいけないという記事を見かけるので、localStorageは使えないんだなーと思っていました。
この辺りがサーバー側の部分とごっちゃになっていた気はしました。
tokenなどの認証情報をlocalStorageに保持しなければ、認証済みかのフラグなどはlocalStorageに保持しても大丈夫

当初

当初は毎回のページにアクセスするタイミングで、サーバー側にリクエストを投げて、認証チェックを行うのが良いのではないかと思っていました。
しかし、それだと、多いときだと1つのページを表示するのに、3回もrequestが投げられてしまいます。
そうなるとユーザビリティが悪くなるため、やめることにしました。

storeに保持するのはどうか

storeに保持する場合だと、リロードしたりすると値が初期化されてしまいます。
sessionが切れるタイミングまで、その情報を保持しておいて欲しいので、storeだと実現が厳しいかなと思いました。

個人的なベストプラクティス

色々な試行錯誤を経て、辿り着いたのがlocalStorageくんでした。

localStorageの使い方については以下の記事を参考にしてください。

なお、方針としましては以下のような形になります。

  • 全体としては、サーバー側に認証済みかのチェック用のメソッドを置いておき、それのレスポンスをlocalStorageに保持する
  • ログイン後にログイン認証とメール認証の判定
  • 新規登録後にログイン認証
  • メール認証完了時にメール認証済みの判定
  • ログアウト時にlocalStorageをクリア
  • 退会時にもlocalStorageをクリア
/**
 * ログイン処理
 */
public static async login(): Promise<void> {
  // 以下ログイン処理完了後
  localStorage.setItem(AuthCheck.IS_AUTHENTICATED, String(ログインしているかのフラグ))
  localStorage.setItem(AuthCheck.IS_EMAIL_VERIFIED, String(メール認証済みかのフラグ))
}

/**
* ログアウト処理
*/
public static async logout(): Promise<void> {
  // ログアウト処理後
  localStorage.clear()
}
  
/**
* 退会処理
*/
public static async resign(): Promise<void> {
  // 退会処理後
  localStorage.clear()
}

/**
* 新規登録処理
*/
public static async signup(): Promise<void> {
  // 新規登録処理後
  localStorage.setItem(AuthCheck.IS_AUTHENTICATED, String(ログインしているかのフラグ))
}

/**
* メール認証完了処理
*/
public static async emailVerify(): Promise<void> {
  // メール認証完了後
  localStorage.setItem(AuthCheck.IS_EMAIL_VERIFIED, String(メール認証済みかのフラグ))
}

以下がrouter部分になります。
meta: { requiresAuth: true }とつけた箇所にルート保護が適用されますので、必要なものに使ってください。

index.ts
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/logout',
      name: 'logout',
      component: Logout,
      meta: { requiresAuth: true },
    },
    {
      path: '/signup',
      name: 'signup',
      component: Signup,
    },
    {
      path: '/resign',
      name: 'resign',
      component: Resign,
      meta: { requiresAuth: true },
    },
    // メール認証完了処理
    {
      path: '/verify-email/:id/:hash',
      name: 'verification.verify',
      component: VerifyEmail,
      meta: { requiresAuth: true },
    },
    // メール認証を誘導する処理
    {
      path: '/verify-email',
      name: 'verification.prompt',
      component: VerifyEmailPrompt,
      meta: { requiresAuth: true },
    },
  ],
})

authenticationGuard(router) // ここでルート保護を適用

そして上記のルーティングのファイルで読んでいるルート保護のメソッドの中身は以下のようになっています。

authGuard.ts
/**
 * ルート保護に関するメソッド
 * @param router
 */
export const authenticationGuard = (router: Router) => {
  router.beforeEach(async (to) => {
    // 認証が必要なルートかどうか
    const isRequiresAuth: boolean = to.matched.some((record) => record.meta.requiresAuth)
    if (!isRequiresAuth) return

    const isAuthenticated: string | null = localStorage.getItem(AuthCheck.IS_AUTHENTICATED) // ログイン認証済みかどうか
    const isEmailVerified: string | null = localStorage.getItem(AuthCheck.IS_EMAIL_VERIFIED) // メール認証済みかどうか

    // NOTE: ルート保護されており且つ、ログインされていない場合はログイン画面に飛ばす
    if (!isAuthenticated || !JSON.parse(isAuthenticated.toLowerCase())) {
      return { name: 'login' }
    }
    // NOTE: メール認証が完了していない場合はメール送信完了画面に飛ばす
    if (
      (!isEmailVerified || !JSON.parse(isEmailVerified.toLowerCase())) &&
      !(to.fullPath.includes('verify-email') || to.fullPath.includes('logout'))
    ) {
      return { name: 'verification.prompt' } // メール認証を誘導する画面へリダイレクトさせる
    }
  })
}

この取り出すときにstringのまま取り出すとfalseが入っている場合でもtrueと認識されてしまいますので気をつけてください。
【JS】true・falseの文字列をBoolean型に変換する方法

withCredentialsをtrueにする

laravel側のcors'supports_credentials' => true,をしたことにより、axiosの withCredentialsをtrueに設定する必要があります。

なお、XSRF_TOKENをサーバーからのresponseから取得して、以降の通信でaxiosのヘッダーに詰めて通信を行うことで、ルート保護を行いながらの通信が可能になります。

axios.ts
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/auth'

// 全ての箇所でこのaxiosインスタンスを使わないとsessionがstartできない
const axiosInstance: AxiosInstance = axios.create()
axiosInstance.interceptors.request.use((config: AxiosRequestConfig<any>) => {
  // ヘッダーにx-xsrf-tokenを詰めて通信を行う
  config.headers = {
    'X-XSRF-TOKEN': useAuthStore().xsrfToken,
  }
  return config
})

axiosInstance.defaults.withCredentials = true
export default axiosInstance

axiosInstance.interceptors.requestでrequestを行う前に共通の処理を入れることができるわけです。
これで共通して、ヘッダーにX-XSRF-TOKENが詰め込まれた状態で通信を行うことができます。
以下の記事などが参考になりました。
React axios インターセプターでフックを使えるようにする

これでクライアント側の設定は終了です。
これで認証済みのユーザーしか画面にアクセスできないようになります。

参考記事

動作確認方法


動作確認方法です。
ログイン前とかでも良いですが、まず/sanctum/csrf-tokenにアクセスします。
これでセッションを使った通信を行うことができるようになります。
今回はログイン処理を行うと同時に、get_emailというメールアドレスを取得する処理を行ってみます。

Login.vue
<template>
  ...  
 <form @submit.prevent="login">
  <input type='text' name='email' v-model='email'>
  <input type='text' name='password v-model='password'>
  <button>ログイン</button>
</form>
  
</template>

<script setup lang="ts">

/** セッションを開始する */
onMounted(async () => {
  await axiosInstance
    .get(http://localhost:8080 + '/sanctum/csrf-cookie')
    .then(async (res) => {
      // x-xsrf-tokenが存在する場合はstoreに保持する
      if (res.config.headers && res.config.headers['X-XSRF-TOKEN']) {
        useAuthStore().setXsrfToken(res.config.headers['X-XSRF-TOKEN'] as string)
      } 
      // ヘッダーにx-xsrf-tokenが存在しない場合はエラーを投げる
      else {
        throw new UnAuthorizedError('xsrftoken is not found.')
      }
    })
    // responseがエラーの場合もエラーを投げる
    .catch((err) => {
      throw new UnAuthorizedError(err)
    })
})

  const email = ref('')
  const password = ref('')

  /** ログイン処理 */
  async function login() {
   await axiosInstance
   .post(http://localhost:8080 + '/login', {email: email.value, password: password.value})
    .then(async (res) => {
	// 成功したときの処理
    })
    .catch((err) => {
       // エラーのときの処理
    })
    
   await axiosInstance
   .get(http://localhost:8080 + '/get_email')
    .then(async (res) => {
	// 成功したときの処理
    })
    .catch((err) => {
       // エラーのときの処理
    })
  }
  
  
</script>

そしてサーバー側にもloginとget_emailに対応したルーティングを用意します。
ログイン時するときはログイン認証をされたら困るので、auth:sanctumの外に置きます。
逆にget_emailにアクセスするのはログインユーザーのみにしたいので、auth:sanctumの中に置きます。

api.php
Route::post('login', [LoginController::class, 'store']);

Route::group(['middleware' => ['auth:sanctum']], function () {
    // メール認証は完了していなくても、ログイン認証が完了していないとアクセスできないルーティング
    Route::get('get_email', [GetEmailController::class, 'index']);
    
    Route::group(['middleware' => ['verified']], function () {
        // メール認証もログイン認証も完了していないとアクセスできないルーティング
    });
});

次にcontrollerの中になります。認証ユーザーのメールアドレスを取得する処理です。
これで、セッションを開始していない且つログインしていないとエラーが発生してしまうかと思います。

GetEmailController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;

class GetEmailController extends Controller
{
    /**
     * emailアドレスの取得処理
     */
    public function index(Request $request)
    {
	$authUser = Auth::user();
	$email = $authUser->email;
        return response()->json($email);
    }
}

まとめ

これまではLaravelのみを使った開発しかやったことなく、SPAだと色々なことを面倒見る必要があるんだなと痛感しました。その分学びになるのですが。。
個人的には、本当にSPA開発をするときは、サーバー側とクライアント側が別々のアプリとして作られていることを意識するとわかりやすいのかなと思いました。

Discussion