laravel 6 + Nuxt.js で作るJWT認証つきSPA構築
技術スタック LaravelNuxt.js

laravel 6 + Nuxt.js で作るJWT認証つきSPA構築

2020.12.07

こんにちはjunです。laravel をAPIサーバーとして扱いフロントはNuxt.jsのSPAで構築するプロジェクトがありました。認証を実装したり初期のセッティングでそこそこ詰まったのでメモがてら記事にします。

この手の記事にありがちな「開発サーバーまでの段階しかやらない」ではなくSPAをビルドしてDocker上の仮想的に作成したサーバー環境で実際に一つのアプリケーションとして動かすとこまでやってみます。

独立したフロントエンド とバックをどうやってつなげて、どんなサーバー構成にすればいいのか?という視点で見ていただければ幸いです。また私も昨日にようやく自分の頭で纏った程度なので、この手のアプリケーション作成がチョットデキル人はぜひアドバイスやコメントお願いします。

Dockerを用いた環境構築概要から話しています。環境がありLravelとNuxtのAPI連携について知りたい人は **「Larvel にJWT認証機能を追加する」**から読みはじめてください。

使用環境 docker:19.03.13 maxOS Catalina:10.15.5 composer:1.10.6 Node.js:12.19.0 Laravel:6.20 Nuxt.js:2.14.6

Dockerで仮想環境を構築

私が今回構築するサーバー構成は以下の図の感じです。

webサーバーではバーチャルホスト を使って、80・443などの通常のHTTP通信に対してはNuxt.jsのビルドファイルを返します。そして8000ポートにはLaravelのプロジェクトをおいて、APIサーバーとして使用します。

Nuxt.jsはLaravelから発行されたトークンを用いてAPIにアクセスしてデータを引っ張ります。実際の運用ではドメインが付与されるので、例えばservice.comとして、http(s)://service.comはNuxt.jsへ、そしてNuxt.jsはhttps://api.service.comへAPIを飛ばします。

バーチャルホスト の設定をすることで公開側とAPIをconfレベルで分けることができます。つまりDockerのwebサーバーイメージには、ApacheまたはNginxの設定ファイルをボリュームして、それぞれのドキュメントルート を指定できるような環境があれば大丈夫です。(無理に私のイメージとかに合わせなくても大丈夫ということです。)

docker-composeは以下のような感じ

こちら記事で作成したイメージを使用します。cenotsから構築したLAMP環境です。centos_apacheイメージを用いてweb側をビルドしています。

docker-compose.yml
version: '3'
services: 
  web_1:
    image: centos_apache:1.0
    depends_on: 
      - db
    volumes: 
      - ./html/:/var/www/html/
      - ./web_1/httpd.conf:/etc/httpd/conf/httpd.conf
      - /sys/fs/cgroup:/sys/fs/cgroup:ro
    ports: 
      - "9000:80"
      - "3000:3000"
      - "8000:8000"
    privileged: true
    command: /sbin/init
  db:
    image: mysql:5.7
    environment:
      MYSQL_DATABASE: trend_system
      MYSQL_USER: trend
      MYSQL_PASSWORD: trendtrend
      MYSQL_ROOT_PASSWORD: rootroot
    ports: 
      - "3306:3306"
    volumes: 
      - laravel_data:/var/lib/mysql
volumes: 
  laravel_data: {}

ホストマシンのlocalhost:9000にアクセスするとコンテナのlocalhost:80につながります。Nuxt.jsの開発サーバーポートとなる3000はホストとコンテナ一緒にしています。多分使わないのですが php artisan serveで立てるLaravel開発サーバーポート8000も一応用意しておきます。

ホスト側にはhtmlディレクトリにfrontendlaravelと言ったディレクトリがあります。Nuxt、Laravelのソースがそれぞれに配置されています。コンテナ起動時にはそのhtmlディレクトリはコンテナの/var/www/htmlにボリュームされます。

httpd.confは以下の感じ

Nginx兄貴達にはすみませんが、とりあえず以下のようなバーチャルホスト 設定が立てられば大丈夫です。

httpd.conf
Listen 8000
<Directory "/var/www/html/frontend/dist">
    Options Indexes FollowSymLinks
    AllowOverride All
    Require all granted
</Directory>

<VirtualHost *:80>
    ServerName example.com
    DocumentRoot /var/www/html/frontend/dist
</VirtualHost>

<VirtualHost *:8000>
    ServerName example.com
    DocumentRoot /var/www/html/laravel/public
</VirtualHost>

forntendはnuxtのプロジェクトディレクトリ、laravelはLaravelのプロジェクトディレクトリ名です。80でリクエストが来たらnuxtのビルドしたindex.htmlがある/var/www/html/frontend/distへ飛ばされます。一応後でブラウザからはアクセスできないようにしますが、8000できたリクエストは/var/www/html/laravel/publicindex.phpがキャッチします。

ぶっちゃけDockerなくてもとりあえず行けます

初心者の人は「?」となるかもしれません。しかしDBがあり、ホストマシン上でnuxtプロジェクトとLaravelお互いの開発サーバ同士で連絡が取れれば、見出しの内容は実装可能です。ビルドまでしたり総合的に確かめたい場合にDockerを使用してください。

Laravelの設定をする

コンテナを起動

docker-compose.ymlがあるディレクトリでdocker-compose up -d をします。特に問題なければコンテナーが起動するはずです。起動したら

docker exec -it {web側のコンテナ名} /bin/bash

をしてコンテナに入りましょう。/var/www/htmlにいくとマウントしたLaravelプロジェクトとNuxt.jsプロジェクトがいるはずです。

laravel のenvファイルを変更

マイグレーションをしたいのでlaravelのenvを設定します。

.env
DB_CONNECTION=mysql
DB_HOST=db // docker-compose.yml で定義したDBのコンテナ
DB_PORT=3306
DB_DATABASE=laravel_test
DB_USERNAME=root
DB_PASSWORD=rootroot

docker-composeのおかげでこの設定であればつながります。コンテナの中/var/www/htmm/laravelにてマイグレーションを実行します。

$ php artisan migrate

Larvel にJWT認証機能を追加する

ここからが本番です。ちなみに私が使用しているLaravel はバージョン6.2.0です。バージョンによって設定が異なったりします。この 記事は2020年12月時点でLravel 8 は出ているけど、LTSなどの都合でLravel 6を使用するという状況で書いています。 sライブラリなどはあえてバージョンを指定したりしていますので注意してください。

JWTライブラリをインストール

Laravelには標準でJTWが入っていないのでライブラリをインストールします。

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

tymon/jwt-auth というライブラリを入れますが、Laravel 6 の場合はバージョンに:1.0.0-rc.5を指定しないとエラーになります。以下はこのライブラリのドキュメント通りの実装です。

設定を一部変更

ライブラリのインストールをすればJWTは使えるようになります。しかし今回の構築では一部変更しなければならない箇所があったので、設定ファイルを生成します。

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

config/jwt.phpというファイルが生成されたはずです。このファイルにおいて以下の部分を書き換えます。

'providers' => [

/*
|--------------------------------------------------------------------------
| JWT Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to create and decode the tokens.
|
*/

// 'jwt' => Tymon\JWTAuth\Providers\JWT\Lcobucci::class,
'jwt' => Tymon\JWTAuth\Providers\JWT\Namshi::class,

]

JWTのプロバイダーをTymon\JWTAuth\Providers\JWT\Namshi::classに変更します。というのも標準の設定でいくと

Tymon\JWTAuth\Exceptions\JWTException: Could not create token: Implicit conversion of keys from strings is deprecated. Please use InMemory or LocalFileReference classes. 

というエラーが生じます。JWTトークンを生成する処理に非推奨な部分があるそうで、プロバイダを変更する必要がありました。参考記事にあるgithub issueがお世話になりました。

シークレットを生成

JWTトークンを生成するためのシークレットを作成します。

php artisan jwt:secret

するとenvファイルの下の方に

JWT_SECRET=ve1b******

というものが生成されます。ちなみにこのシークレットは絶対に外部に漏れてはいけません。間違ってgithubとかに上げないように .envファイルはgitignoreしましょう。

Userモデルにメソッドを追加

app/User.php
<?php

namespace App;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Tymon\JWTAuth\Contracts\JWTSubject; //追加

class User extends Authenticatable implements JWTSubject //追加
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password', 'remember_token',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    public function getJWTIdentifier()  //追加
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()  //追加
    {
        return [];
    }
}

auth.phpの認証ドライバを変更

config/auth.phpにてAuthentication認証ドライバを変更します。

config/auth.php
    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option controls the default authentication "guard" and password
    | reset options for your application. You may change these defaults
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => 'api', //変更
        'passwords' => 'users',
    ],
...
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'api' => [
            'driver' => 'jwt', //変更
            'provider' => 'users',
            'hash' => false,

configはキャッシュされていますので、configをいじった後はキャッシュクリアをしましょう。

php artisan config:clear

JWT認証用のルートを作成

認証のルートを作成します。route/api.phpに以下のように記述します。

route/api.php
Route::prefix('v1')->group(function(){
    Route::group(['middleware' => 'api', 'prefix' => 'auth'], function ($router) {
        Route::post('login', 'AuthController@login')->name('login');;
        Route::post('logout', 'AuthController@logout');
        Route::post('refresh', 'AuthController@refresh');
        Route::get('me', 'AuthController@me');
    });
});

ルートの設定は運用によって変わると思いますが、APIはバージョンで予め分けておくと将来の拡張性が高まります。一回ぽっきりのサービスでも実装して損はないと思います。上記のルートでは以下のような設定になります。

$ php artisan route:list
+--------+----------+---------------------+-------+---------------------------------------------+--------------+
| Domain | Method   | URI                 | Name  | Action                                      | Middleware   |
+--------+----------+---------------------+-------+---------------------------------------------+--------------+
|        | POST     | api/v1/auth/login   | login | App\Http\Controllers\AuthController@login   | api          |
|        | POST     | api/v1/auth/logout  |       | App\Http\Controllers\AuthController@logout  | api,auth:api |
|        | GET|HEAD | api/v1/auth/me      |       | App\Http\Controllers\AuthController@me      | api,auth:api |
|        | POST     | api/v1/auth/refresh |       | App\Http\Controllers\AuthController@refresh | api,auth:api |
+--------+----------+---------------------+-------+---------------------------------------------+--------------+

認証ルートに対するコントローラを作成

それぞれのルートはAuthController.phpにつなげる予定ですので、そのコントローラーを作成します。

php artisan make:controller AuthController

AuthController.phpは以下のような設定になります。

app/Http/Controllers/AuthController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    /**
     * Create a new AuthController instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth:api', ['except' => ['login']]);
    }

    /**
     * Get a JWT via given credentials.
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function login()
    {
        $credentials = request(['email', 'password']);

        if (! $token = auth()->attempt($credentials)) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        return $this->respondWithToken($token);
    }

    /**
     * Get the authenticated User.
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function me()
    {
        return response()->json(auth()->user());
    }

    /**
     * Log the user out (Invalidate the token).
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function logout()
    {
        auth()->logout();

        return response()->json(['message' => 'Successfully logged out']);
    }

    /**
     * Refresh a token.
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function refresh()
    {
        return $this->respondWithToken(auth()->refresh());
    }

    /**
     * Get the token array structure.
     *
     * @param  string $token
     *
     * @return \Illuminate\Http\JsonResponse
     */
    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth()->factory()->getTTL() * 60
        ]);
    }
}

これでJWT認証に必要なLarvel側の設定が終了しました。

Talend API TesterでAPIをチェック

きちんとAPIが存在しているかを確かめるために、chrome拡張のTalend API Testerを用いてチェックします。予めシーダーで作成した仮ユーザーデータを用いて以下のように入力してみます。

JSONでアクセストークン が帰ってきました。このアクセストークン をリクエストヘッダに入れてリクエストすることで認証が必要なルートにアクセスできるようになります。試しに /api/v1/auth/meという現在のユーザー情報を確かめるルートにアクセスしてみます。

HEADERSにAuthorizationというものを追加し、先ほどのトークンを入れています。トークン値は「bearer トークン」という形です。

ユーザー情報が返ってきましたね。Authcontroller@meで定義した返り値がきちんとえられました。ちなみに認証情報がない場合はHTTP 401を返します。

Nuxt.jsからAPIを呼ぶ

前準備

Nuxt.jsの構築に移る前にCORS対策をします。APIサーバーはブラウザからのアクセスを禁止して、XMLHttpRequest(XHR)のみのリクエストを許可するようにします。そしてXHRにはCORSという制約があります。これがあるとローカル以外からのXHRを用いたAPIアクセスが制限されます。特にNuxtを3000ポートで開発している時でもAPIサーバーと通信できるように準備しておきます。

CORS設定のライブラリをインストール

composer require fruitcake/laravel-cors

app/Http/Kernel.phpのミドルウェア に追加

app/Http/Kernel.php
protected $middleware = [
    // ...
    \Fruitcake\Cors\HandleCors::class,
];

CORS設定ファイルを出力して一部変更

php artisan vendor:publish --tag="cors"

先ほどのjwt.phpのようにcors.phpconfig/配下に出現します。そのcors.phpを以下のように変更します。

confog/cors.php
...
    /*
     * You can enable CORS for 1 or multiple paths.
     * Example: ['api/*']
     */
    'paths' => ['api/*'],
...
    /*
     * Matches the request origin. `['*']` allows all origins. Wildcards can be used, eg `*.mydomain.com`
     */
    'allowed_origins' => [
          'http://localhost',
          'http://localhost:3000',// nuxt
          'http://localhost:9000' // dockerの9000->80
        ],

...

api/ ルート配下に対してcors設定を行い、そしてlocalhost及び3000ポートからの接続を許可しました。

nuxt auth モジュールを用いて認証系の機能を整える

nuxt.jsの構築は飛ばします。私の環境では nuxt+bootstrap+axiosで始めます。認証系をpluginで構築してもいいのですが、なかなか大変ですのでnuxt authモジュールを使用します。このモジュールがあれば認証つきのNuxtアプリを簡単に作成できます。本家のドキュメントに習いながら進めましょう。

モジュールのインストール

nuxt.jsのプロジェクトルートに移動いして以下のモジュールをインストールします。

npm install @nuxtjs/auth-next @nuxtjs/axios

nuxt.config.jsでの設定

nuxt.config.jsでまずモジュールの読み込みをします。

...javascript[nuxt.config.js]
// Modules (https://go.nuxtjs.dev/config-modules)
  modules: [
    '@nuxtjs/axios',
    '@nuxtjs/auth'
  ],
...

これでauthモジュールとaxiosモジュールが使えます。authモジュールはstoreを使用しますので、storeにindex.jsがない場合は空でもいいので作っていきます。

そしてauthモジュールの設定をnuxt.config.jsで行います。

nuxt.config.js
...
auth:{
    localStorage: false,
    strategies:{
      local:{
        tokenType:'bearer',
        endpoints:{
          login:{
            url:'/auth/login',
            method:'post',
            propertyName:'access_token'
          },
          logout:{
            url:'/auth/logout',
            method:'post',
          },
          user:{
            url:'/auth/me',
            method:'get',
            propertyName:false
          }
        }
      },
      redirect: {
        login: '/login',
        logout: '/',
        callback: '/login',
        home: '/home'
      }
}
...

ここでは予めauthモジュールで使用するログイン用のルートを指定したり、使用する通信パターンを定義します。

JWTトークンをローカルストレージに入れておくのは危ないらしいので、localStorage: falseとしておきます。そしてstrategiesの中で通信パターンやルートの定義を行います。

今はlocalという通信パターンしかありませんが、outhとかapi2とか他のapiサーバーに対しての通信パターンを複数定義できます。

tokenTypeで先ほどのbeareを指定しておきます。こうすると自動的にauthorizationヘッダーにbeareという文字を追加してくれます。endopointsでそれぞれのログイン(login)、ログアウト(logout)、ユーザー確認(user)、それぞれのルートを指定します。

指定しないとauthモジュールのデフォルトのURLでアクセスしてしまいます。特にuserはページが読み込みされた際に、トークンをサーバーに送ってログイン状態かどうかをNuxt.jsに伝える機能があります。ここをキチンと設定しないとNuxt側で現在ログインが必要なページが開けないなどの状態に陥ります。

axiosの設定

最後にaxiosの設定をします。

nuxt.config.js
const ENV = require('dotenv').config().parsed;
export default {
... 
env:ENV,
axios: {
    baseURL: ENV.API_BASE_URL,
  },
...
}

nuxtのdotenvモジュールを用いて.envファイルからbaseURLつまりAPIのアクセス先ルートを指定します。.envは以下のようになっています。

.env
API_BASE_URL=http://localhost:8000/api/v1

今はローカルの開発環境でやっていますが本番はlocalhostではなくドメインになったりします。環境ごとに異なる値は.envに記述して環境ごとに.envファイルを作成してそれをインポートします。

ログインフォームを整える

デフォルトのページとかを削除してログインフォームとかを用意します。今回はbootstrapで構築しました。このようにヘッダーがあり、「ログイン」をクリックすると入力モーダルが現れます。

この送信をクリックするとnuxt authの関数、loginwith()が実行されます。コンポーネントレベルですが以下のコードになっています。

component/loginModal
<template>
    <b-modal
      id="login-modal"
      ref="modal"
      title="ログイン情報を入力してください。"
      ok-only
      :okTitle="'送信'"
      @show="resetModal"
      @hidden="resetModal"
      @ok="handleOk"
    >
      <form @submit.stop.prevent="handleSubmit">
        <b-alert v-if="loginErrMes" show variant="danger">{{loginErrMes}}</b-alert>
        <b-form-group
          :state="emailState"
          label="メールアドレス"
          type="email"
          label-for="name-input"
          invalid-feedback="メールアドレスは入力必須です。"
        >
          <b-form-input
            id="login-email-input"
            v-model="email"
            :state="emailState"
            required
          />
        </b-form-group>

        <b-form-group
          :state="passState"
          label="パスワード"
          label-for="name-input"
          invalid-feedback="パスワードは入力必須です。"
          autocomplete="username"
        >
          <b-form-input
            id="login-password-input"
            v-model="pass"
            type="password"
            :state="passState"
            autocomplete="current-password"
            required
          />
        </b-form-group>
      </form>
    </b-modal>
</template>

<script>
import { required, minLength, between } from 'vuelidate/lib/validators'
export default {
    name:'loginModal',
    data(){
        return{
            email:'',
            pass:'',
            emailState:null,
            passState:null,
            loginErrMes:null,
        }
    },
    validations:{
      email:{
        required,
      },
      pass:{
        required
      },
    },
    methods:{
        checkFormHasError(){
            this.emailState = !this.$v.email.$invalid;
            this.passState = !this.$v.pass.$invalid;
            return this.$v.$invalid;
        },
        resetModal(){
            this.email='';
            this.pass='';
            this.emailState=null;
            this.passState=null;
            this.loginErrMes=null;
        },
        handleOk(bvModalEvt){
            bvModalEvt.preventDefault()
            this.handleSubmit();
        },
        async handleSubmit() {
            if(this.checkFormHasError()) return;

            try{
              await this.$auth.loginWith('local', { data:{
                email:this.email,
                password:this.pass
              }})
              this.resetModal();
              this.$store.dispatch('message/setFlashMessage',{
                content:'ログインしました。',
                messageType:'success'
              })
              this.$bvModal.hide('login-modal')
            }catch(error){
              this.loginErrMes='パスワードまたはメールアドレスが異なります。';
            }
        }
    },
}

このアプリでは後でいろいろフォームとかある予定なのでvuelidateというバリデーションライブラリを入れています。このログインフォーム程度であれば必要ありませんけど。ログイン処理をしているのは下の方にあるasync handleSubmit()です。入力値が正規値であれば実行されます。

nuxt authはnuxt.config.jsで定義した設定を元にログインのルートにデータをPOSTします。超便利です。

先ほどテストで入れたようにシーダーのアドレスとパスワードを入れて送信します。

ネットワークを見てみるとキチンと8000ポートで待機しているlaravelへ送信されています。レスポンスが200で成功しています。クッキーを見てみるとトークンが保存されているのが分かります。

そしてユーザー情報があるか=ログインしているかで「ログイン」を「ログアウト」を制御しています。

authモジュールを入れている場合は以下のコードでログインしているか、ユーザーの情報を取得することができます。

this.$auth.user
this.$auth.loggedIn

NuxtのSPAをビルドする

Nuxtは3000ポートの開発サーバで見ていたので、今度はキチンとビルドしてユーザー視点でアクセスしてみましょう。

npm run build

distファイルが出されたのを確認しlocalhost:9000へアクセスします。私の環境ではホストのlocalhost:9000はコンテナのlocalhost:80につながります。最初の設定の通り、ドキュメントルート はdist配下に通じているのでindex.htmlが返されます。ログインが成功し、ネットワークでもAPIが送信されているのが分かります。

後はどんどんAPIルートを作成して、nuxtからはaxiosを用いてトークン付きリクエストを送れば認証ルートにアクセスすることができます。これでJWT認証つきのSPAアプリの設定が完了しました。

以上!

以上がLravel6とNuxt.jsで構築するJWT認証つきSPAの構築です。サーバーの構成は人によって様々ですがバーチャルホスト で公開側ページとAPIを分けてしまうのが簡単な気がします。とにかくトークン認証を用いたSPAを構築する際には

  • Nuxt側とAPI側でサーバーを分ける
  • laravelにJWT認証ライブラリを入れる
  • ログイン用ルートを整える
  • CORSの設定を行う
  • NuxtからのログインルートにPOSTリクエストを送る
  • トークンをブラウザのクッキーなどに保存
  • 認証ルートにはリクエストヘッダにBeare+トークンでリクエストをする
  • おのつど、またはページがリロードされたら /api/auth/meでトークンが有効かを確かめてNuxt側に認証状態を知らせる。

以上のまとめを意識すればおおよそのNuxt+API認証はわかってくると思います。ReactとかNext.jsなどもこの部分のエッセンスは同じなのでぜひ応用してください。

参考記事

Copyright © 2021 jun. All rights reserved.