Python Django チュートリアル SPA編(6)


勉強会用の資料です．
今回の記事ではユーザログイン情報の確認や復元を行えるようにしていきます．

ソースコードはgithubで管理してます．
https://github.com/usa-mimi/tutorial-spa
今回の記事から開始する人は `tutorial6-start` のタグから開始してください．

# はじめに

前回の記事までで，ログイン画面で正しいIDとパスワードを入力するとログイン状態になるところまで作成しました．
ユーザがログインしているかどうかの情報は axiosクライアント(`this.$request`)にtokenがセットされているかどうかで一応の判断はできますが，
*ユーザがログイン済みかどうか* という情報の判断をAPI用に使用しているプラグインに問い合わせるのは好ましくありません．
また，今はまだないですが，ログイン中のユーザの名前などは多くのコンポーネントで使用することが想定されるため，
どのコンポーネントのdataで定義すべきなのか判断がつきません．

vue.jsには [vuex](https://vuex.vuejs.org/ja/intro.html) と呼ばれるライブラリが提供されており，
これを使うことでコンポーネント間で共通して使用するデータを楽に共有することが可能になります．

![vuexのパターン図](https://vuex.vuejs.org/ja/images/vuex.png)

上述の公式の図を見てわかる通り，vuexはvueのデータ処理部分を抜き出して共通化したライブラリと言えます．
vueコンポーネントでは通常，`data` でデータを定義し， テンプレート内で記述したボタン等のhtmlに
`methods` で定義したメソッドを紐付けます．
呼び出されたメソッド内ではデータを変更し，テンプレート内で使用されているデータに対して変更を反映します．

vuexではデータを **state**，メソッドを **action** として定義します．
また，vuex内で **mutation** と呼ばれるメソッドを持っており， stateを変更できるのはmutationのみ，と設定されています．
少し回りくどいように感じるかもしれませんが，このような設計によってAPIのような非同期な処理を同期的な処理に落とし込めるようになっています．

公式ドキュメントに詳しい思想やカウンタでのサンプルコードが記載されているのでぜひ一度目を通してください．
https://vuex.vuejs.org/ja/getting-started.html

# ログイン情報の確認

## vuexのインストール

ソース: `b90f807` → `be10d0c`

npmでさくっと入ります．
`frontend/` ディレクトリに移動し，
`$ npm install --save vuex` とコマンド実行してください．


## vuexソースコードの記述

ソース: `be10d0c` → `0c90ee7`

慣例的に `store` としているのでそれに合わせます．

`frontend/src/` 以下に storeディレクトリを作りましょう．
[公式のサンプル](https://vuex.vuejs.org/ja/structure.html)ではactionやmutation用にjsファイルを作ってますが，
今回はとりあえず `index.js` に全部いれちゃいます．
最初なのでまずはログインしているかどうかだけ保持し，状態を切り替えるためのactionとmutationを用意します．


```javascript:frontend/src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

const state = {
  isLoggedIn: false,
}

const mutations = {
  loggedIn (state) {
    state.isLoggedIn = true
  },
  loggedOut (state) {
    state.isLoggedIn = false
  },
}

const actions = {
  login ({commit}) {
    commit('loggedIn')
  },
  logout ({commit}) {
    commit('loggedOut')
  },
}

const getters = {
  isLoggedIn: state => state.isLoggedIn,
}

export default new Vuex.Store({
  strict: debug,
  actions,
  getters,
  mutations,
  state,
})
```

ここまで書けたら `main.js` を修正し，Vueにセットします．


```javascript:frontend/src/main.js
...
import store from './store'  // <----- 追加
import api from './api'

...
new Vue({
  el: '#app',
  store,  // <----- 追加
  router,
  components: { App },
  template: '<App/>',
})
```

このままだと動作がわからないので適当な場所に入れて動作確認しましょう．
今回は `App.vue` に書いていきます
template内にはログイン中かどうかの情報を表示させます．
また，ログイン用，ログアウト用のボタンを配置します．

`vuex` には `mapActions`，`mapGetters` というヘルパーが用意されています．
これをそれぞれmethods, computedに記述することでメソッドや算術プロパティとして登録できます．

```vue:frontend/src/App.vue
<template>
  <v-app id="app">
    <v-navigation-drawer app></v-navigation-drawer>
    <v-toolbar app>
      {{ isLoggedIn ? 'ログイン中' : 'ログアウト中' }}
      <v-btn @click="login">ログイン</v-btn>
      <v-btn @click="logout">ログアウト</v-btn>
    </v-toolbar>
    <v-content>
      <v-container fluid>
        <router-view></router-view>
      </v-container>
    </v-content>
    <v-footer app></v-footer>
  </v-app>
</template>

<script>
import {mapActions, mapGetters} from 'vuex'

export default {
  name: 'App',
  computed: {
    ...mapGetters(['isLoggedIn']),
  },
  methods: {
    ...mapActions(['login', 'logout']),
  },
}
</script>
```

ブラウザで開いて動作確認しましょう．
今回はルートコンポーネントに差し込んだのでトップページでもログイン画面でもヘッダの位置に文字とボタンが表示されます．

![Kobito.91O0Bt.png](https://qiita-image-store.s3.amazonaws.com/0/58048/fd87ebda-5fd1-60df-2949-7710c8314931.png "Kobito.91O0Bt.png")


## ログイン処理の記述

ソース: `0c90ee7` → `ae560d3`

ボタンを押すことでvuexのstate変更ができるようになりましたが，
この状態では本当のログイン操作は行えていません．
`store/index.js` のloginアクションを拡張して実際のログイン処理を記述します．
現状ではLogin.vue内でログインボタン押下時に `this.$request` からAPIを直接叩いていましたが，
ログイン状態のvuex側で保持する必要があるのでAPIの呼び出しをvuexのactionに移動させます．

以下修正した部分の抜粋です．

```javascript:frontend/src/store/index.js
import Vuex from 'vuex'

import client from '@/api' // 追加

...

const mutations = {
  loggedIn (state, token) {  // 引数追加
    state.isLoggedIn = true
    client.defaults.headers.common['Authorization'] = `JWT ${token}`  // ここでtokenセット
  },
...
const actions = {
  login ({commit}, [username, password]) {  // 引数追加，配列で受け取るので展開して受け取る
    return client.auth.login(username, password).then(res => {
      commit('loggedIn', res.data.token)
      return res
    })
  },
```

Login.vueでやっていたAPIの呼び出しや成功時の動作の一部をstoreに移動させただけです．
Vueコンポーネント内では `this.$request` で参照していましたが，store内では **client** としてimportしてます．

actionsのloginメソッドの第2引数ですが，ここはvuexでmethodをmapする際に2つ以上の引数が上手く渡せないので
呼び出し元で配列で渡すようにしています．そのため，ここでは受け取った配列を展開するように記述しています．

続いてLogin.vue側です．

```vue:frontend/src/components/Login.vue
<script>
import {mapActions} from 'vuex'  // 追加

export default {
  name: 'Login',
...
  methods: {
    ...mapActions(['login']),
    submit () {
      this.nonFieldErrors = []
      this.login([this.username, this.password]).then(res => {
        this.$router.push('/')
      }, err => {
        this.nonFieldErrors = err.response.data.nonFieldErrors
      })
    },
  },
}
```

こちらはscriptの頭で `mapActions` をimportしてloginアクションを使えるようにしています．
`this.$request`の時もアクションの`login`の時の戻り値はPromiseなので記述自体は殆ど変わりません．
tokenのセット処理をstore内に移動させているのでその部分だけ削除しています．
`this.login`の引数が配列になっている点に注意してください．

## ログアウト処理とAppの修正

ソース: `ae560d3` → `42f7864`

ログイン処理を入れたので次はログアウト処理です．
ログイン時にはヘッダにtokenをセットしましたが，ログアウト時にはこれを消してあげましょう．
この後にページ遷移させたいならコンポーネント側に処理が必要ですが，取り敢えず今回はこれで．

```javascript:frontend/src/store/index.js
const mutations = {
...
  loggedOut (state) {
    state.isLoggedIn = false
    delete client.defaults.headers.common['Authorization']
  },
}
```

ついでにApp.vueで記述したテスト用のヘッダも修正します．
今は常にログイン／ログアウトボタンがついていますが，ログアウト時にはログイン画面へのリンク，
ログイン時にはログアウトボタンを表示するようにします．

```vue:frontend/src/App.vue
    <v-toolbar app>
      <router-link v-show="!isLoggedIn" :to="{name: 'Login'}">ログイン</router-link>
      <v-btn v-show="isLoggedIn" @click="logout">ログアウト</v-btn>
    </v-toolbar>
```

もうかなり慣れてきたと思いますが，コンポーネントの表示／非表示は `v-show` を使います．
> v-if でもいいです．
v-showの場合，非表示時にもDOMは存在するが，表示はされていない，という状態になります．
v-ifの場合は非表示時にはDOM自体が存在せず，表示状態になった時に作成されます．
[公式を参考に](https://jp.vuejs.org/v2/guide/conditional.html#v-if-vs-v-show)どちらを使うか考えてみてください．

`<router-link>` は vue-routerが持っているコンポーネントです．
`to`としてnameを渡すと `router/index.js` で定義した名前のコンポーネントからURLが逆引きできます．

# ログイン情報の復元

ソース: `42f7864` → `c64c47c`

今の状態だとブラウザのF5を押すとログアウト状態になってしまいます．
これはコンポーネントやvuexが持つデータはどこにも保存されず，javascript再読込時に初期化されるからです．
これを防ぐためには，ログイン成功時に取得したtokenをcookieかlocalStorageに保存する必要があります．
今回はlocalStorageに保存することにします．
> Qiita: [sessionとcookieとWeb Storageの違い](https://qiita.com/cobachan/items/05fa537a4ffcb189d001)

まずはstoreのmutationを修正し，ログイン時にlocalStorageに保存，ログアウト時にlocalStorageから削除する処理を挟みます．
また，localStorageからの復元を試みるactionを追加します．

```javascript:frontend/src/store/index.js
const mutations = {
  loggedIn (state, token) {
    state.isLoggedIn = true
    client.defaults.headers.common['Authorization'] = `JWT ${token}`
    localStorage.setItem('token', token)  // <---- 追加
  },
  loggedOut (state) {
    state.isLoggedIn = false
    delete client.defaults.headers.common['Authorization']
    localStorage.clear()  // <--- 追加
  },
}

const actions = {
...
  tryLoggedIn ({commit}) {
    const token = localStorage.getItem('token')
    if (token) {
      commit('loggedIn', token)
    }
  },
}
```

storeの修正ができたら今度は呼び出し処理です．
今回は一番TOPのコンポーネントであるApp.vueが作られるタイミングでローカルストレージからの復元処理を呼び出すようにします．
修正は以下のようになります．

```vue:frontend/src/App.vue
   created () {  // イベント登録
     this.tryLoggedIn()
   },
   methods: {
   ...mapActions(['login', 'logout', 'tryLoggedIn']),  // 'tryLoggedIn' 追加
   },
```

ここまでできたらブラウザで一度ログインし，ブラウザを更新してみましょう．
更新後もログイン状態が保持されていれば（ログアウトボタンがヘッダに表示されていれば）成功です．


![Kobito.uuzS7y.png](https://qiita-image-store.s3.amazonaws.com/0/58048/40926c2b-e92a-11ca-227c-e7e886ca1adc.png "Kobito.uuzS7y.png")


# tokenの検証処理を入れる

今はtokenがlocalStorageに保存されていればログイン状態になってしまいます．
しかし，tokenの有効期限が切れていたり，ユーザそのものが削除されていたりし，本当はログイン状態ではない可能性も考えられます．
そこで，localStorageからtokenを復元する際に検証を行い，tokenが正しい場合のみログイン処理を行うように修正します．

## token検証APIの追加

ソース: `c64c47c` → `25731fa`

まずはdjango側に手を入れます．
token発行用のAPIを作った時のように，token検証用のviewもすでにライブラリで提供されています．
従って，やることはurls.pyの記述だけです．

```python:tutorial/urls.py
from rest_framework_jwt.views import obtain_jwt_token
from rest_framework_jwt.views import verify_jwt_token  # <--- 追加
...
api_urlpatterns = [
    path('auth/', obtain_jwt_token),
    path('auth/verify/', verify_jwt_token),  # <--- 追加
    path('questions/', include(question_router.urls)),
...
```

これでtokenをpostして正しいかどうか調べるための準備ができました．
不正な値を入れると下記のようにステータスコード400が返ります．

![Kobito.hikEHo.png](https://qiita-image-store.s3.amazonaws.com/0/58048/0eb6c74f-db73-b51e-b6d1-e8ebfd8618ef.png "Kobito.hikEHo.png")


## 検証用APIの呼び出し処理追加
ソース: `25731fa` → `de22002`

まずクライアントから呼び出せるように検証用APIを追加します．

```javascript:frontend/src/api/auth.js
export default function (cli) {
  return {
...
    verify (token) {
      return cli.post('auth/verify/', {token})
    },
  }
}
```

次に，storeのtryLoggedInアクションを修正し，APIの戻り値が正しい場合のみログイン処理を行うことにします．
APIの戻り値が正しくない場合，無効なtokenがlocalStorageに残ってることになるので削除しておきます．

```javascript:frontend/src/store/index.js
  tryLoggedIn ({commit}) {
    const token = localStorage.getItem('token')
    if (token) {
      client.auth.verify(token).then(() => {
        commit('loggedIn', token)
      }, () => {
        // 不正なtoken
        localStorage.clear()
      })
    }
  },
```

これで復元時にtokenの検証を行う処理完了です．
---

次回はログインしないと入れないページを作っていきます．

→ [チュートリアルまとめ](http://qiita.com/maisuto/items/17653f344d1f64afa019)