66
79

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-03-18

目次

四部作です。

  1. はじめに

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

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

JWTAuth をインストール

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

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

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

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

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をローカルストレージに保存していましたが、クッキーの方がセキュリティ上良いようです。

[/追記]

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>

以上で終わりです。

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

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

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

所感

Vue.js超楽しい。

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

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

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

(ちょっとだけ宣伝)

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

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

TODO

  • Vue.js側のユニットテスト

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

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

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

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

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

参考

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

66
79
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
66
79

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?