Vue3とLaravelにおけるルート保護
Vue3とlaravelを使用してアプリ開発を行っています。
今回はVue3とlaravelを使用した完全SPAのアプリにおけるルート保護について説明していきます。
環境
- Mac(Intel)
- Vue3.2
- Laravel8.75
- Laravel Sanctum2.11
- Docker
- Vue-router
- Vite
結論
結論から言いますと、以下の形になるかと思います。
- ログイン情報自体はサーバー側のsessionにて管理する形にする
- クライアント側は認証済みか必要になったときにサーバー側にリクエストを投げて認証済みかをチェックする
- その結果(true/falseなど)をlocalStorageに保持する
ちなみに、どこに保持させるべきかの論争は以下の参考記事に書かれていますので、そちらを見ていただければと思います。
参考記事
- JWTは使うべきではない 〜 SPAにおける本当にセキュアな認証方式 〜
- SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜
前提
まず前提として、クライアント側のルート保護とサーバー側のルート保護があるかと思います。
- クライアント側は、そのページにアクセスしたときにどのページは閲覧可能か
- サーバー側はクライアント側から、リクエストが投げられたときにそのデータを保存するかなど
それではそれぞれ見ていきましょう!
サーバー側
サーバー側には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
の部分ですね。
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内を書き換えます
'supports_credentials' => true,
そしてここをtrue
に変えるとaxios
のwithCredentials
をtrueにしてあげる必要があります。
詳しくはクライアント側の方で、説明します。
さらに、env
ファイルの方を以下に変更す。
こうすることで、セッションidがクッキーによって管理されるようになります。
...
SESSION_DRIVER=cookie
さらに、自分は以下の形でmiddlewareを設定して、サーバー側のルート保護を行いました。
今回laravelはAPIとして使用しますので、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 }
とつけた箇所にルート保護が適用されますので、必要なものに使ってください。
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) // ここでルート保護を適用
そして上記のルーティングのファイルで読んでいるルート保護のメソッドの中身は以下のようになっています。
/**
* ルート保護に関するメソッド
* @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
のヘッダーに詰めて通信を行うことで、ルート保護を行いながらの通信が可能になります。
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 インターセプターでフックを使えるようにする
これでクライアント側の設定は終了です。
これで認証済みのユーザーしか画面にアクセスできないようになります。
参考記事
- Vue 3 Auth Guard(未認証の場合にログインページにリダイレクト)
- axiosのinterceptorsで、リクエストの前処理を共通して行う
- 初心者の私がLaravel+Vue.jsでSPAを作るときのCSRFトークンでつまずいたこと
動作確認方法
動作確認方法です。
ログイン前とかでも良いですが、まず/sanctum/csrf-token
にアクセスします。
これでセッションを使った通信を行うことができるようになります。
今回はログイン処理を行うと同時に、get_email
というメールアドレスを取得する処理を行ってみます。
<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
の中に置きます。
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の中になります。認証ユーザーのメールアドレスを取得する処理です。
これで、セッションを開始していない且つログインしていないとエラーが発生してしまうかと思います。
<?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