Laravel 5.4 と Vue.js 2.2 と JWTAuth で、ログインできる SPA アプリケーションのチュートリアル その4

  • 11
    いいね
  • 1
    コメント

目次

四部作です。

  1. はじめに
  2. Todoアプリ作成編 / サーバーサイド
  3. Todoアプリ作成編 / フロントエンド
  4. JWTAuthでログイン編 ← 今ここ

今回、 JWTAuth を利用してユーザーがログインできるようにしていきます。

※ユーザー登録はやりません

JWTAuth をインストール

JWTAuthに必要なパッケージを導入します。
Laravel では、かなり導入しやすくできていると感じました。

使うパッケージは https://github.com/tymondesigns/jwt-auth です。

ほぼほぼ上記のWikiに従っていけばOKです。

まずはcomposerコマンドを叩きます。

# jwt-auth module を追加
composer require "tymon/jwt-auth"

次に、各設定ファイルから登録します。

config/app.php
// ...

    'providers' => [

        // ...

        Tymon\JWTAuth\Providers\JWTAuthServiceProvider::class,
    ],

    // ...

    'aliases' => [

        // ...

        'JWTAuth' => Tymon\JWTAuth\Facades\JWTAuth::class,
        'JWTFactory' => Tymon\JWTAuth\Facades\JWTFactory::class,
    ],
];

JWTAuth の設定ファイルを作ります。

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\JWTAuthServiceProvider"
php artisan jwt:generate

最後に、JWTAuth Routing で使えるようにします。

app/Http/Kernel.php
    protected $routeMiddleware = [

        // ...

        'jwt.auth' => \Tymon\JWTAuth\Middleware\GetUserFromToken::class,
        'jwt.refresh' => \Tymon\JWTAuth\Middleware\RefreshToken::class,
    ];
}

インストールはこれで終了です。

認証用 Routing 定義

認証用に新しいAPI用Routingをいくつか追加します。

  • /api/authenticate ログインするため
  • /api/logout ログアウトのため
  • /api/tasks ログインしているユーザーのタスクのみ返すように変更
  • /api/me ログインしているユーザーの情報を返す
routes/api.php
Route::group(['middleware' => 'api'], function () {
    Route::post('authenticate',  'AuthenticateController@authenticate');

    Route::group(['middleware' => 'jwt.auth'], function () {
        Route::resource('tasks',  'TaskController');
        Route::get('me',  'AuthenticateController@getCurrentUser');
    });
});

jwt.auth というミドルウェアを追加することで、アクセスを制御できます。

今まで /api/tasks にアクセスするとランダムにタスクを返していましたが、
認証しているユーザーのタスクのみを見れるようにしていきます。

認証用 Controller の作成

ログインを管理するコントローラーを作ります。

php artisan make:controller AuthenticateController

# => Controller created successfully.
app/Http/Controllers/AuthenticateController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;
use App\User;

class AuthenticateController extends Controller
{
    public function authenticate(Request $request)
    {
        // grab credentials from the request
        $credentials = $request->only('email', 'password');

        try {
            // attempt to verify the credentials and create a token for the user
            if (! $token = JWTAuth::attempt($credentials)) {
                return response()->json(['error' => 'invalid_credentials'], 401);
            }
        } catch (JWTException $e) {
            // something went wrong whilst attempting to encode the token
            return response()->json(['error' => 'could_not_create_token'], 500);
        }

        $user = User::where('email', $request->email)->first();

        // all good so return the token
        return response()->json(compact('user', 'token'));
    }

    public function getCurrentUser()
    {
        $user = JWTAuth::parseToken()->authenticate();
        return response()->json(compact('user'));
    }
}

JWTAuth::attempt($credentials) が通ると、トークンが発行されます。

認証に成功したら、ユーザー情報とトークンを返します。

現在のユーザーと取得するメソッドもここで作りました。
後で使います。

Model の修正

ここからしばらく、 User has many Tasks の関係を作り、Taskがユーザーに紐づくようにしていきます。

Task Model

まず、 tasks テーブルに user_id を追加します。

マイグレーションを作ります。

php artisan make:migration add_user_id_to_tasks

# => Created Migration: 2017_03_18_084344_add_user_id_to_tasks

編集します。

2017_03_18_084344_add_user_id_to_tasks.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddUserIdToTasks extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('tasks', function (Blueprint $table) {
            $table->integer('user_id')->references('id')->on('users')->unsigned()->index()->nullable();
        });

    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('tasks', function (Blueprint $table) {
            $table->dropColumn('user_id');
        });
    }
}

※ nullableにしているのはSQLiteのため

実行します。

php artisan migrate

# => Migrated: 2017_03_18_084607_add_user_id_to_tasks

User Model

hasMany を追加します。

app/User.php
    // 追加
    public function tasks()
    {
        return $this->hasMany(Task::class);
    }

Task の方のAssociationは今回は飛ばします。

User Factory

ユーザーを自動生成する Factory を定義します。

database/factories/ModelFactory.php
// ...
$factory->define(App\User::class, function (Faker\Generator $faker) {
    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'password' => bcrypt('secret'),
        'remember_token' => str_random(10),
    ];
});
// ...

User has many Tasks なので、 ユーザーにタスクが紐づくように Seeder を修正します。

database/seeds/DatabaseSeeder.php
    public function run()
    {
        factory(App\User::class, 50)->create()->each(function ($user) {
            $user->tasks()->save(
                factory(App\Task::class)->make()
            );
        });
    }

データを投入します。

php artisan db:seed

Task Controller

ユーザーに紐付いたタスクを返すようにします。

app/Http/Controllers/TaskController.php
    public function index()
    {
        $user = \JWTAuth::parseToken()->authenticate();
        return $user->tasks()->get()->keyBy('id');
    }

    public function store(Request $request)
    {
        $user = \JWTAuth::parseToken()->authenticate();
        return $user->tasks()->create($request->only('name'))->fresh();
    }

Curl コマンドで実験

ログインできるようになっていることを curl でテストします。

コンソールに入って、適当なユーザーを選択します。

# 適当なユーザーを選択する
php artisan tinker

>>> App\User::first()
=> App\User {#701
     id: "1",
     name: "Margarette Kshlerin",
     email: "laura.cartwright@example.com",
     created_at: "2017-03-17 13:28:22",
     updated_at: "2017-03-17 13:28:22",
   }

次にそのユーザーでログインしてみます。

curl -XPOST localhost:8000/api/authenticate -d 'email=laura.cartwright@example.com' -d 'password=secret'

{
  "user": {
    "id": 1,
    "name": "Margarette Kshlerin",
    "email": "laura.cartwright@example.com",
    "created_at": "2017-03-17 13:28:22",
    "updated_at": "2017-03-17 13:28:22"
  },
  "token": "eyJ0eXAiOiJ******W2gvafWitgza_2H5A-g_1xS5SBkZPHde8tE"
}

無事にTokenが発行されました。

次回以降、Vue.jsから送るリクエストでは、このTokenを使って認証済みユーザーということを証明します。

その前に、ちゃんと認証ができているかテストしてみましょう。

ヘッダにTokenを入れて、 /api/tasks にリクエストしてみます。

curl -XGET localhost:8000/api/tasks -H 'Authorization: Bearer eyJ0eXAiOiJ******W2gvafWitgza_2H5A-g_1xS5SBkZPHde8tE'

{
  "1": {
    "id": 1,
    "name": "Thora Strosin",
    "is_done": false,
    "created_at": "2017-03-16 22:39:49",
    "updated_at": "2017-03-16 22:39:49",
    "user_id": "1"
  },
  "2": {
# ...
  },
  "5": {
    "id": 5,
    "name": "August Denesik",
    "is_done": true,
    "created_at": "2017-03-16 22:39:49",
    "updated_at": "2017-03-16 22:39:49"
    "user_id": "5",
  }
}

ちゃんと取得できていますね。

認証できていないパターンの検証をします。

まずはトークンを入れずに /api/tasks にリクエストしてみます。

curl -XGET localhost:8000/api/tasks

{"error":"token_not_provided"}

弾かれてます。

次に、間違ったトークンを入れて /api/tasks にリクエストしてみます。

curl -XGET localhost:8000/api/tasks -H 'Authorization: Bearer hoge.fuga.piyo'

{"error":"token_invalid"}

同じく弾かれてます。

これでログインが機能していることを確認できました。

リクエストに認証用ヘッダー追加

axios の設定をいじり、認証用の Authorization ヘッダーを追加します。

resources/assets/js/services/http.js
  // ...

  delete (url, data = {}, successCb = null, errorCb = null) {
    return this.request('delete', url, data, successCb, errorCb)
  },


  /**
   * Init the service.
   */
  init () {
    axios.defaults.baseURL = '/api'

    // Intercept the request to make sure the token is injected into the header.
    axios.interceptors.request.use(config => {
      config.headers['X-CSRF-TOKEN']     = window.Laravel.csrfToken
      config.headers['X-Requested-With'] = 'XMLHttpRequest'
      config.headers['Authorization']    = `Bearer ${localStorage.getItem('jwt-token')}` // これを追加
      return config
    })

    // ↓ここから追加
    // Intercept the response and ...
    axios.interceptors.response.use(response => {
      // ...get the token from the header or response data if exists, and save it.
      const token = response.headers['Authorization'] || response.data['token']
      if (token) {
        localStorage.setItem('jwt-token', token)
      }

      return response
    }, error => {
      // Also, if we receive a Bad Request / Unauthorized error
      console.log(error)
      return Promise.reject(error)
    })
  }
// ...

こうしておくことで、

  • services/http.js 初期化時に、ローカルストレージから token を取得してヘッダーに入れる
  • axios がログイン成功時のレスポンスヘッダーを見てローカルストレージに token を保存

してくれます。

[追記]

参考にしたプロジェクトでは、Tokenをローカルストレージに保存していましたが、クッキーの方がセキュリティ上良いようです。

https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage

[/追記]

Storeパターンを使ってログイン状態を維持

Vue.jsで、コンポーネントを超えた状態管理及びデータバインディングについて考えます。

コンポーネントに分割していると、様々なコンポーネントから参照したい情報が出てきます。

例えば、ユーザーがログインしているかどうかに応じて、
各コンポーネントが表示する内容が変わる、
というのはよくある状況です。

これを実現するために、いくつか方法があり

  • 親コンポーネントに情報を全部乗せ、子コンポーネントから this.$parent で参照する
  • 状態を持ったモジュールを Store という別ファイルに外だしして、コンポーネント間で共有する
  • vuex を使う

今回は参考にした koel が2番めの方法を使っていたので、それに従いました。
Storeパターンというらしいです。

state.png

この規模なら十分です。

c.f.
State Management
https://vuejs.org/v2/guide/state-management.html

では User Store を作ります。

resources/assets/js/stores/userStore.js
import http from '../services/http'

export default {
  debug: true,
  state: {
    user: {},
    authenticated: false,
  },

  login (email, password, successCb = null, errorCb = null) {
    var login_param = {email: email, password: password}
    http.post('authenticate', login_param, res => {
      this.state.user = res.data.user
      this.state.authenticated = true
      successCb()
    }, error => {
      errorCb()
    })
  },

  setCurrentUser () {
    http.get('me', res => {
      this.state.user = res.data.user
      this.state.authenticated = true
    })
  },

  /**
   * Init the store.
   */
  init () {
    this.setCurrentUser()
  }
}

state: の部分が、「状態」です。

各コンポーネントがこれを読み込んでも、状態は共有され、
データバインディングもされるので便利です。

また、init() メソッドで /api/me を見に行って、自身の情報を取得しています。
この辺は自分の設計が最適化されていない・・・気がする

あとはこの stores/userStore.js を親コンポーネントで読み込みかつ初期化してあげます。

resources/assets/js/app.js
// ...
import userStore from './stores/userStore'

// ...

const app = new Vue({
  router,
  el: '#app',
  created () {
    http.init()
    userStore.init()
  },
  render: h => h(require('./app.vue')),
})

ログイン

ついにここまで来ました。

Login コンポーネントで、ログインの処理を書きます。

resources/assets/js/components/Login.vue
<template>
  <div>
    <div class="container">
      <div class="row">
        <div class="col-md-8 col-md-offset-2">
          <div class="panel panel-default">
            <div class="panel-heading">Login</div>
            <div class="panel-body">
              <label for="email" class="col-md-4 control-label">E-Mail Address</label>

              <div class="alert alert-danger" role="alert" v-if="showAlert">
                {{ alertMessage }}
              </div>

              <div class="form-group">
                <div class="col-md-6">
                  <input id="email" type="email" class="form-control"
                         v-model="email" @keyup.enter="login" required autofocus>
                </div>
              </div>

              <label for="password" class="col-md-4 control-label">Password</label>
              <div class="form-group">
                <div class="col-md-6">
                  <input id="password" type="password" class="form-control"
                         v-model="password" @keyup.enter="login" required autofocus>
                </div>
              </div>
              <div class="form-group">
                <div class="col-md-8 col-md-offset-4">
                  <button @click="login" type="submit" class="btn btn-primary">
                    Login
                  </button>
                </div>
              </div>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
  import userStore from '../stores/userStore'
  import http from '../services/http'

  export default {
    mounted () {
      this.fetchUsers()
    },
    data() {
      return {
        email: '',
        password: '',
        showAlert: false,
        alertMessage: '',
      }
    },
    methods: {
      login () {
        userStore.login(this.email, this.password, res => {
          this.$router.push('/')
        }, error => {
          this.showAlert = true
          this.alertMessage = 'Wrong email or password.'
        })
      },
    }
  }
</script>

Enterキーまたはログインボタンを押下で、リクエストを飛ばして認証するようにしています。

成功したら、トップページに遷移します。

また、失敗した場合はアラート表示しています。

次に、ログイン状態の有無でナビゲーションバーの表示を変えます。

  • ログイン済 => ログインしているユーザーの名前を表示
  • 未ログイン => ログインへのリンク

となるようにします。

resources/assets/js/components/Navbar.vue
      <!-- Collect the nav links, forms, and other content for toggling -->
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <ul class="nav navbar-nav navbar-right">
          <li><router-link to="/about">About</router-link></li>

          <!-- ここから追加 -->
          <li class="dropdown" v-if="userState.authenticated">
            <a href="#" class="dropdown-toggle"
               data-toggle="dropdown"
               role="button" aria-haspopup="true" aria-expanded="false">
               {{ userState.user.name }}
               <span class="caret"></span>
            </a>
            <ul class="dropdown-menu">
              <li><a href="#">Log out</a></li>
            </ul>
          </li>
          <li v-else>
            <router-link to="/login">Log in</router-link>
          </li>

        <!-- ... -->


<script>
  import userStore from '../stores/userStore'

  export default {
    data (){
      return {
        userState: userStore.state
      }
    },
  }
</script>

ログアウトの処理はこの後書きます。

更に、タスク一覧でログインしている時は "please login" という表示を出さないようにします。

resources/assets/js/components/Tasks.vue
<template>
  <div>
    <div v-if="userState.authenticated">
      <strong>Hello, {{ userState.user.name }}!</strong>

      <!-- ... -->

    <p v-else>
      please <router-link to="/login">Login.</router-link>
    </p>
  </div>
</template>

<script>
  import http from '../services/http'
  import userStore from '../stores/userStore' // 追加

  export default {
    mounted() {
      this.fetchTasks()
    },
    data() {
      return {
        tasks: [],
        name: '',
        showAlert: false,
        alertMessage: '',
        userState: userStore.state,      // 追加
      }
    },
  }
</script>

ここまでで、こんな感じになっていると思います。完成は近い。

out.gif

ログアウト

ローカルストレージに保存した jwt-token を削除するだけです。
また、サーバーサイドでは jwt.refresh という Middleware が設定された Routing にアクセスするだけで、
トークンが破棄されます。

今回はナビゲーションバーからログアウトするようにしたいので、 components/Navbar.vue をいじります。

まずは Routing を定義します。

routes/api.php
Route::group(['middleware' => 'api'], function () {

// ...

    Route::get('logout',  'AuthenticateController@logout')->middleware('jwt.refresh');

// ...

});

AuthenticateControllerにメソッドを追加します。

app/Http/Controllers/AuthenticateController.php
    public function logout()
    {
    }

次に、userStore にログアウトの処理を追加します。

resources/assets/js/stores/userStore.js
  // To log out, we just need to remove the token
  logout (successCb = null, errorCb = null) {
    http.get('logout', () => {
      localStorage.removeItem('jwt-token')
      this.state.authenticated = false
      successCb()
    }, errorCb)
  },

上記のURLにアクセスした後、ローカルストレージからjwt-tokenを削除するだけです。

最後に、 Navbar のログアウトのリンクに、ログアウトの処理を書きます。

resources/assets/js/components/Navbar.vue
      <!-- ... -->
            <ul class="dropdown-menu">
              <li><a @click="logout()">Log out</a></li>
            </ul>
      <!-- ... -->

<script>
  import userStore from '../stores/userStore'

  export default {
    data (){
      return {
        userState: userStore.state
      }
    },
    methods: {
      logout() {
        userStore.logout( () => {
          this.$router.push('/login')
        })
      }
    }
  }
</script>

以上で終わりです。

これで、ほぼデモ通りのアプリケーションができたのではないかと思います。

https://powerful-temple-39976.herokuapp.com/

正確には、スピナーを入れるために
vue-spinner 入れて、 event 使ってたりするんですが、またの機会にいたします。

ここまで読んでくれて、本当にありがとうございました。

所感

Vue.js超楽しい。

大げさかもしれないが、SPAこそがWebのあるべき姿なんじゃないかと思い始めた。

セッションを駆使し、状態管理をサーバーサイド+クライアントサイドで行うのは
State LessというWebの原則からして無理があるのではなかろうか。

SEOで不利、という話もあるが、ユーザー体験が良くなるのだから、むしろ有利に働くべきだと考える。

(ちょっとだけ宣伝)

今後、自社サービスの管理画面にVue.jsを入れていく予定です。

一緒にVue.jsを書きたい方、ぜひお待ちしております!

https://www.wantedly.com/companies/co-media/projects

TODO

  • Vue.js側のユニットテスト
  • JWTAuthでユニットテスト (認証付けたら今は通らなくなっちゃった)

  • 他のユーザーのタスクを読み書きできないようにするポリシー設定

    • 今はPOST, PUT, DELETE メソッドで普通にアクセスできちゃう発行
  • 入力のバリデーション

  • mix 使って assets ファイルを管理する(今は適当)

  • Removeしたタスクのアーカイブ機能とか

参考

JWTAuthの参考に致しました。
http://qiita.com/kz_morita/items/f770e8de906074107e57