Edited at

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

More than 1 year has passed since last update.


目次

四部作です。


  1. はじめに

  2. Todoアプリ作成編 / サーバーサイド

  3. Todoアプリ作成編 / フロントエンド

  4. JWTAuthでログイン編 ← 今ここ

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

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