目次
四部作です。
- Todoアプリ作成編 / サーバーサイド
- Todoアプリ作成編 / フロントエンド
- JWTAuthでログイン編 ← 今ここ
今回、 JWTAuth
を利用してユーザーがログインできるようにしていきます。
※ユーザー登録はやりません
JWTAuth をインストール
JWTAuthに必要なパッケージを導入します。
Laravel では、かなり導入しやすくできていると感じました。
使うパッケージは https://github.com/tymondesigns/jwt-auth です。
ほぼほぼ上記のWikiに従っていけばOKです。
まずはcomposerコマンドを叩きます。
composer require tymon/jwt-auth
次に、各設定ファイルから登録します。
// ...
'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 で使えるようにします。
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
ログインしているユーザーの情報を返す
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.
<?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
編集します。
<?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
を追加します。
// 追加
public function tasks()
{
return $this->hasMany(Task::class);
}
Task
の方のAssociationは今回は飛ばします。
User Factory
ユーザーを自動生成する Factory を定義します。
// ...
$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 を修正します。
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
ユーザーに紐付いたタスクを返すようにします。
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
ヘッダーを追加します。
// ...
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パターンというらしいです。
この規模なら十分です。
c.f.
State Management
https://vuejs.org/v2/guide/state-management.html
では User Store を作ります。
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
を親コンポーネントで読み込みかつ初期化してあげます。
// ...
import userStore from './stores/userStore'
// ...
const app = new Vue({
router,
el: '#app',
created () {
http.init()
userStore.init()
},
render: h => h(require('./app.vue')),
})
ログイン
ついにここまで来ました。
Login コンポーネントで、ログインの処理を書きます。
<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キーまたはログインボタンを押下で、リクエストを飛ばして認証するようにしています。
成功したら、トップページに遷移します。
また、失敗した場合はアラート表示しています。
次に、ログイン状態の有無でナビゲーションバーの表示を変えます。
- ログイン済 => ログインしているユーザーの名前を表示
- 未ログイン => ログインへのリンク
となるようにします。
<!-- 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" という表示を出さないようにします。
<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>
ここまでで、こんな感じになっていると思います。完成は近い。
ログアウト
ローカルストレージに保存した jwt-token
を削除するだけです。
また、サーバーサイドでは jwt.refresh
という Middleware が設定された Routing にアクセスするだけで、
トークンが破棄されます。
今回はナビゲーションバーからログアウトするようにしたいので、 components/Navbar.vue
をいじります。
まずは Routing を定義します。
Route::group(['middleware' => 'api'], function () {
// ...
Route::get('logout', 'AuthenticateController@logout')->middleware('jwt.refresh');
// ...
});
AuthenticateControllerにメソッドを追加します。
public function logout()
{
}
次に、userStore にログアウトの処理を追加します。
// 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 のログアウトのリンクに、ログアウトの処理を書きます。
<!-- ... -->
<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でユニットテスト (認証付けたら今は通らなくなっちゃった)- こんな感じにすれば良さげ → この方法でできた。https://gist.github.com/jwalton512/e3b320bef85be88e8bd5
- それとも
WithoutMiddleWare
使えばいいのかな
-
他のユーザーのタスクを読み書きできないようにするポリシー設定
- 今はPOST, PUT, DELETE メソッドで普通にアクセスできちゃう発行
-
入力のバリデーション
-
mix
使ってassets
ファイルを管理する(今は適当) -
Removeしたタスクのアーカイブ機能とか
参考
JWTAuthの参考に致しました。
http://qiita.com/kz_morita/items/f770e8de906074107e57