【Laravel6 / Vue2】Laravel6+Vue.jsでJWT認証機能を実装

JWTを利用した認証をLaravelで実装することができるライブラリのjwt-authを使用して、Vue+Laravelアプリの認証機能を作成したので、メモがてら手順をまとめてみました。
VueRouterの`beforeEach`を使用した閲覧制限機能も実装します。

SPAで認証機能を実装される方の参考になれば嬉しいです😄

[こちらの記事](https://qiita.com/whopper1962/items/602bb294ac64081cc69c)で紹介しているような手順で、VueとLaravelの連携が完了している前提で進めていきます!

> ### バージョン
> composer: 2.2.7
> php: 7.4.28
> laravel: 6.20.44
> npm: 6.14.12
> node: 14.16.1
> vue: 2.5.17
> vue-router: 3.1.3



## 認証機能の流れ
- 今回の認証機能は、以下のような流れとなっています。

![スクリーンショット 2022-06-13 1.36.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/e42609c6-0a8d-c2fb-6686-6e5543142db3.png)

1. ログインAPI成功時に、バックエンド(Laravel)からJWTが発行されるので、フロントエンド(Vue)でローカルストレージにJWTを保存します

2. axiosを利用したAPIリクエスト時に、`Authorization`,`Accept`,`Content-Type`の3つのヘッダーを付与する。そのうちの1つ`Authorization`に、ローカルストレージに保存したJWTを持たせます

3. ミドルウェア(`auth:api`)を適用したルートグループに入っているAPIを実行した際は、Laravel側でjwt-authによるJWTのチェック処理が走ります。JWTが不正な場合は401エラーを出し、正常の場合は正常なレスポンスを返します

4. axiosのレスポンス時のインターセプターで、もし401エラーが返ってきた場合は`1`でローカルストレージに保存したJWTを破棄して、ログイン画面へ遷移させます

5. ログアウトAPI実行時には、Laravelが保持するJWTを破棄するので、フロント側でもローカルストレージに保存したJWTを破棄します

## JWT Authをインストール
- JWT（JSON Web Token）を使用して認証を行うことができるライブラリ[JWT Auth](https://jwt-auth.readthedocs.io/en/docs/)をインストールします

~~~bash
composer require tymon/jwt-auth:1.0.0-rc.5
~~~

- 以下コマンドも実行してください

~~~bash
composer require lcobucci/jwt:3.3.3
~~~

※ 👆を実行していない場合、jwt-authで使用しているライブラリ`lcobucci`のバージョン依存により以下のようなエラーが起きます

~~~bash
Could not create token: Using integers for registered date claims is deprecated, please use DateTimeImmutable objects instead.
~~~

- 以下コマンドで、JWT Auth設定ファイル`config/jwt.php`を作成します
~~~bash
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
~~~


## シークレットキーを生成
- 以下コマンドを実行して、JWTを復元/生成する為に必要なシークレットキーが発行されます
~~~bash
php artisan jwt:secret
~~~
- `.env`ファイルに`JWT_SECRET`という値が追加されていれば正常にシークレットキーが発行できています

## Userモデルの編集
- Userモデル（`User.php`）でJWT Authを使う為に必要な記述を追加していきます
- `use`に`Tymon\JWTAuth\Contracts\JWTSubject`を追加します
~~~php:User.php
use Tymon\JWTAuth\Contracts\JWTSubject; // 追加
~~~

- クラス宣言部に`implements JWTSubject`を追加します
~~~php:User.php
// 変更前
class User extends Authenticatable

// 変更後
class User extends Authenticatable implements JWTSubject

~~~

- 以下のメソッドを追加します
~~~php:User.php
public function getJWTIdentifier()
{
    return $this->getKey();
}

// JWTのペイロードに何か値を追加したい場合はここで追加
public function getJWTCustomClaims()
{
    return [];
}
~~~

### `config/auth.php`の編集
- 認証の設定が記載されているファイル`config/auth.php`を編集していきます
#### `defaults` を編集
~~~php:auth.php
// 変更前
'defaults' => [
    'guard' => 'web',
    'passwords' => 'users',
],
// 変更後
'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],
~~~
#### `guards`を編集
~~~php:auth.php
// 変更前
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'token',
        'provider' => 'users',
        'hash' => false,
    ],
],
// 変更後
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],
~~~

## `AuthController`の作成
- 認証関係のコントローラーとして`AuthController`を作成していきます
- 以下コマンドで`AuthController`を作成します
~~~bash
php artisan make:controller AuthController
~~~

- 作成された`AuthController`を以下のように編集します
- こちらは[jwt-auth公式ドキュメント](https://jwt-auth.readthedocs.io/en/docs/quick-start/)を参考に作成しました
~~~php:AuthController.php
<?php
namespace App\Http\Controllers;

class AuthController extends Controller
{
    public function login()
    {
        $credentials = request(['email', 'password']);
        if ($token = auth('api')->attempt($credentials)) {
            return $this->respondWithToken($token);
        }
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    public function logout()
    {
        auth()->logout();
        return response()->json(['message' => 'Successfully logged out']);
    }

    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth("api")->factory()->getTTL() * 60
        ]);
    }
}
~~~

## `api.php`の編集
- 認証が必要なAPIは、ミドルウェア(`auth:api`)を適用したルートグループに入れるておきます(後ほどAPI認証のテストとして使用するAPIのルートも追加しています）
- このグループに入れたAPIが実行された場合は、リクエスト時に認証チェックが走るようになります
~~~php:api.php
// 認証が不要なAPIはグループ外に記述
Route::post('/login', 'AuthController@login');
Route::get('/for_everyone', 'TestController@forEveryone');

// 認証が必要なAPIは以下グループ内に記述
Route::group(['middleware' => 'auth:api'], function () {
    Route::get('/requires_auth', 'TestController@requiresAuth');
    Route::post('/logout', 'AuthController@logout');
});
~~~

## API認証のテストとして使用するAPIを作成
- `TestController`を作成します
~~~bash
php artisan make:controller TestController 
~~~
- 作成した`TestController`で、文字列を返すメソッドを２つ用意しておきます
~~~php:TestController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class TestController extends Controller
{
    public function forEveryone () {
        return '認証が不要なAPIです';
    }

    public function requiresAuth () {
        return '認証が必要なAPIです';
    }
}

~~~

## これでLaravel側の設定は終了です。以下でVue側の設定をしていきます!


## VueRouterのbeforeEachで閲覧制御を実装

![スクリーンショット 2022-06-13 1.36.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/ae6c62ac-a8f8-bbc7-46bb-b1ebca093515.png)
- VueRouterでは、ルート定義時に`meta`を追加することでメタフィールドを定義することができます。認証が必要な画面のルートには、`meta`を追加して、`requiresAuth: true` を追加しましょう
~~~javascript:router.js
import VueRouter from "vue-router";
import Home from "./components/Home";
import Login from "./components/Login";
import RequiresAuth from "./components/RequiresAuth";

const routes = [
    {
        path: "/login",
        component: Login,
        name: "LOGIN",
    },
    {
        path: "/home",
        component: Home,
        name: "HOME",
    },
    {
        path: "/requires_auth",
        component: RequiresAuth,
        name: "REQUIRES_AUTH",
        // 認証が必要なルートには以下を追加
        meta: {
            requiresAuth: true,
        },
    },
    {
        path: "*",
        redirect: {
            name: "HOME"
        }
    },
];

const router = new VueRouter({
    mode: "history",
    routes,
});

export default router;
~~~

## beforeEachを実装
> ### beforeEachとは
> - ルート遷移時に挟む処理を書くことができるVueRouterの機能です。
> - 引数は`to`(どのルートに遷移しようとしているか)と、`from`(どのルートから遷移しようとしているか)と、`next`(toへ実際に遷移するという関数) の3つです。
> - `next()`を実行することで、目的のルートへの遷移処理を実行することができます。

#### beforeEach内の処理の流れ
1. 目的のルートのメタフィールドに`requiresAuth: true`があるかどうかを調べる
2. もし遷移先のmetaの中に`requiresAuth: true` がなければ、`next()` を実行して目的のルートに遷移
4. もし遷移先のmetaの中に`requiresAuth: true` があれば、ローカルストレージからjwtトークンを取得して、有効期限を見る
5. もし、JWTが存在しない、または有効期限が切れている場合は、ローカルストレージのトークンを削除してログイン画面へ遷移
6. もし有効期限が切れていない場合は、`next()`を実行して目的のルートに遷移

- `router.js`ファイルの`const router = new VueRoute`と`export default router;`の間に以下のbeforeEachを追加してください
~~~javascript:router.js
router.beforeEach((to, from, next) => {
    if (to.meta.requiresAuth) {
        // ルートのmeta.requiresAuthがtrueの場合、ローカルストレージからJWTを取得
        const token = localStorage.getItem("authorization_token");
        if (!token) {
            // jwtトークンがない場合はキャッシュを破棄してログインページに遷移
            console.error('認証エラー!!');
            localStorage.removeItem("authorization_token");
            next({ name: "LOGIN" });
            return;
        }
        const base64Url = token.split(".")[1];
        const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
        const decodedToken = JSON.parse(
            decodeURIComponent(escape(window.atob(base64)))
        );
        const expireDate = new Date(decodedToken.exp * 1000);
        const now = new Date();
        const isValidToken = now < expireDate;
        if (isValidToken) {
            // jwtが有効期限内の場合は通常の遷移
            next();
        } else {
            // jwtの有効期限が切れている場合は、キャッシュを破棄してログインページに遷移
            console.error('認証エラー!!');
            localStorage.removeItem("authorization_token");
            next({ name: "LOGIN" });
        }
    } else {
        // 目的のルートが認証を必要としない場合はJWTのチェックを行わない
        next();
    }
});
~~~

## axiosインターセプターの実装
- JWT Authを入れたことにより、認証が必要なAPIを叩く際は、以下3つのヘッダーを追加しなければエラーになるようになりました

~~~javascript
Authorization: 'Bearer {JWT}' // 認証時に発行されたJWT
Accept: 'application/json' // 固定値
Content-Type: 'application/json' // 固定値
~~~

- axiosでAPIリクエストを送る処理を書く度に上記ヘッダーを定義するのは面倒なので、今回はaxiosのインターセプターを利用します。

~~~javascript
import axios from 'axios';

axios.interceptors.request.use((request) => {
    // リクエスト送信前の処理
    return request
});

axios.interceptors.response.use((response) => {
    // レスポンス受信時の処理（成功時）
    return response;
}, (error) => {
    // レスポンス受信時の処理（エラー発生時）
    return error;
});
~~~

- 今回、インターセプターで共通化したい処理は以下の二つです。
> 1. リクエストを送る際に3つのヘッダーを追加する
> 2. レスポンスが401エラーだった場合にキャッシュを破棄してログイン画面へ遷移させる

- 1の処理はリクエスト送信時のインターセプターに記載して、2はレスポンス受信時のインターセプターに記載していきます。
~~~javascript:app.js
import axios from 'axios';

// axiosリクエストインターセプター
axios.interceptors.request.use((request) => {
    // ローカルストレージからauthorization_tokenを取得
    const token = localStorage.getItem('authorization_token');
    // ヘッダーに必要な値を追加
    request.headers = {
        'Authorization': token ? `Bearer ${token}` : '',
        'Accept': "application/json",
        'Content-Type': 'application/json'
    };
    return request
});

// axiosレスポンスインターセプター
axios.interceptors.response.use((response) => {
    // 成功時の処理
    return response;
}, (error) => {
    // エラー発生時の処理
    // 401の場合キャッシュを削除してloginページに飛ばす
    if (error.response.status === 401) {
        console.error('認証エラー!!');
        localStorage.removeItem('authorization_token');
        router.push({
            name: 'LOGIN'
        });
    }
    return error;
});
~~~


## 今回は以下3つのコンポーネントを用意して、それぞれ機能を追加していきます。

![スクリーンショット 2022-06-19 2.51.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/b46e8b01-2e34-4c32-9e92-875cd2a67bfc.png)


#### ログイン画面(`Login.vue`)の実装

- ログインボタン
~~~html:Login.vue
<template>
  <div>
    <div class="container">
      <h2>ログインフォーーーム</h2>
      <input class="form-control" type="text" v-model="email" placeholder="Enter email"/>
      <input class="form-control" type="password" v-model="password" placeholder="Password"/>
      <button class="btn btn-primary" type="button" @click="login()">LOGIN</button>
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      email: "",
      password: "",
    };
  },
  methods: {
    login() {
      axios.post("/api/login", {
        email: this.email,
        password: this.password,
      }).then((res) => {
        const token = res.data.access_token;
        // JWTをローカルストレージに保存
        localStorage.setItem("authorization_token", token);
        // メニュー一覧へ遷移
        this.$router.push({
          name: "HOME"
        });
      }).catch((err) => {
        console.error(err);
      });
    },
  },
};
</script>
<style scoped>
.container {
  margin: auto;
  width: 60%;
  padding: auto;
}
</style>


~~~

- Vue+Laravelで認証機能を実装する記事の多くがJWTをキャッシュする際にVuexを利用している記事が多い印象でした。
- Vuexはリロードでデータが消えるので、JWTが消えてしまう為、今回はローカルストレージを使用してみました。
> ### ローカルストレージとは
> - ブラウザ内のデータを保存する領域。
> - クッキーとの違いは、保存期間の有無。クッキーは保存する期間を指定する必要があるが、ローカルストレージはデータを削除する処理を実行しない限り、半永久的にデータを保持する。（保存できるデータ容量も差がある。Cookieは4KBでローカルストレージは5MB）
> - 検証ツールを開き、「アプリケーション」を選択し、「ストレージ」セクションにある「ローカルストレージ」を選択すれば保存されたデータを見れます。



#### 認証が必要な画面(`RequiresAuth.vue`)の実装
2. クリックイベントでログアウトAPIを実行しましょう
3. 処理成功時に、ログイン時にローカルストレージに保存したJWTを破棄して、ログイン画面へ遷移させる処理を書く
~~~html:RequiresAuth.vue
<template>
  <div class="container">
    <h2>認証が必要な画面</h2>
    <button @click="logout()" style="color:red;">ログアウト</button>
  </div>
</template>
<script>
export default {
  data () {
    return {
      apiData: ''
    };
  },
  methods: {
    // ログアウト処理
    logout () {
      axios.get('/api/logout').then((response) => {
        // ローカルストレージからJWTを破棄
        localStorage.removeItem('authorization_token');
        // ログイン画面へ遷移
        this.$router.push({
          name: 'LOGIN'
        });
      }).catch((error) => {
        console.error(error);
      });
    }
  }
}
</script>

<style scoped>
.container {
  margin: auto;
  width: 60%;
  padding: auto;
}
</style>
~~~

#### 認証が不要な画面(`Home.vue`)の実装
~~~html:Home.vue
<template>
  <div class="container">
    <h2>認証が不要な画面</h2>
    <router-link :to="{name:'REQUIRES_AUTH'}">認証が必要な画面に遷移</router-link><br>
    <button @click="executeApiRequiresAuth()">認証が必要なAPIを実行</button>
    <button @click="executeApi()">認証が不要なAPIを実行</button>
    <h3>Data: <span style="color:red;">{{ apiData }}</span></h3>
  </div>
</template>
<script>
export default {
  data () {
    return {
      apiData: ''
    };
  },
  methods: {
    executeApi () {
      axios.get('/api/for_everyone').then((response) => {
        this.apiData = response.data;
      }).catch((error) => {
        console.error(error);
      });
    },
    executeApiRequiresAuth () {
      axios.get('/api/requires_auth').then((response) => {
        this.apiData = response.data;
      }).catch((error) => {
        console.error(error);
      });
    }
  }
}
</script>

<style scoped>
.container {
  margin: auto;
  width: 60%;
  padding: auto;
}
</style>
~~~

## システムが整ったので、ユーザーデータを追加してみましょう
- データベースとの連携はできている前提で話をしていきます🙇
- usersテーブルのマイグレーションはデフォルトで作成されているので、以下コマンドでusersテーブルを作成します
~~~bash
php artisan migrate
~~~
- 以下コマンドでユーザーテーブルのシーダーを作成します
~~~bash
php artisan make:seeder UsersTableSeeder
~~~
- 作成された`UsersTableSeeder`を編集していきます
~~~php:UsersTableSeeder.php
<?php

use Illuminate\Support\Facades\DB;
use Illuminate\Database\Seeder;

class UsersTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('users')->insert([
            'name' => 'TestUser',
            'email' => 'test@test.com',
            'password' => bcrypt('password'),
        ]);
    }
}

~~~
- 以下コマンドでシーダー(`UsersTableSeeder`)を実行します
~~~bash
php artisan db:seed --class=UsersTableSeeder
~~~


## 実際に動かしてみましょう

#### ログインしていない状態
- まずは`/home`にアクセスしてみます

![スクリーンショット 2022-06-19 4.06.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/fd75191a-0cb3-4eb2-5e86-d29d8a6ecbc4.png)


- 「認証が不要なAPIを実行」ボタンを押下して、認証が不要なAPIを実行してみましょう
- 問題なく実行できます

![スクリーンショット 2022-06-19 4.07.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/d7a5c6c6-bc30-58d8-a794-698c7366a6bc.png)


- 「認証が必要なAPIを実行」ボタンを押下して、認証が必要なAPIを実行しましょう
- laravelから401エラーが返り、ログイン画面に遷移するはずです

- 「認証が必要な画面に遷移」リンクを押下して、認証が必要な画面(`/requires_auth`)に遷移してみましょう
- beforeEachのガードに引っかかり、ログイン画面に遷移するはずです

#### ログインしている状態
- ログイン画面`/login`にアクセスしてみます
- 作成したユーザーデータの情報をフォームに入力して、「ログイン」ボタンを押下します
- ログインが成功し、`/home`に遷移するはずです

![スクリーンショット 2022-06-19 4.08.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/d636db3d-b9fe-41b2-b09f-b1aff2136a7c.png)

![スクリーンショット 2022-06-19 4.06.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/fd75191a-0cb3-4eb2-5e86-d29d8a6ecbc4.png)


- ログインAPIを実行した際に発行されたJWTが、ローカルストレージに登録されていることが確認できます。

![スクリーンショット 2022-06-19 4.10.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/f10fc4c8-999d-824c-a85d-c79ec536d91b.png)


- 「認証が必要なAPIを実行」ボタンを押下して、ログインしていない状態では実行できなかった認証が必要なAPIを実行してみましょう
- ログインしているので、問題なくAPIが実行できます

![スクリーンショット 2022-06-19 4.08.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/b1435fde-4865-36d2-beef-834ef787520b.png)



- 「認証が必要な画面に遷移」リンクを押下して、認証が必要な画面(`/requires_auth`)に遷移してみましょう
- ログインしているので、beforeEachのガードに引っかからずに遷移します

![スクリーンショット 2022-06-19 4.09.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/6fd6f0e5-397f-6382-2234-b6b09d037080.png)

- ログアウトボタンを押してみましょう
- ローカルストレージからjwtトークンが削除され、ログイン画面に遷移します

![スクリーンショット 2022-06-19 2.39.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/b09ac2a1-d3c8-5114-0cd3-50c179d69ace.png)

![スクリーンショット 2022-06-19 4.11.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/9dd3244a-07a3-0be1-dcff-5d2df24dd8b9.png)



## 実装完了🔥
少し長くなりましたが、以上でLaravelとVueを連携したアプリの認証機能の実装が完了しました!

ご指摘、質問等ありましたら、是非コメントお願いします。

読んでいただき、ありがとうございました!