Vue.js + Vuetify でログインしないと閲覧できないSPAサイトを作成する

# 概要
JSフレームワークとして **Vue.js** 、CSSフレームワークとして **Vuetify** を用い、 ログインしてないとログイン画面に飛ばされる `SPA (Single Page Application)` 型サイトを作成してみます。

# 注意事項
今回の方式だと、ビルドされたHTMLが静的に置かれるため、ソースを解析すれば未ログインでも **ログイン後のHTML** が取得できてしまいます。
とは言え、肝心のデータは別途 **API** から取得するため、ソースだけ取得しても重要なデータにはアクセスされないですし、ここでは問題無しと割り切っています。
ソースを見られるのすら避けたい場合は、HTMLも **認証プログラム経由** で取得するような仕組みにする必要があるでしょう。

# セットアップ
今回は、 `Vuetify` をベースにした Adminテンプレート [Vue Material Admin](https://github.com/tookit/vue-material-admin) を使います。
こちらを使わなくても、素の `Vuetify` でプロジェクトを作成しても特に問題無く進められると思います。
(できるだけ削ったつもりですが、一部、Adminテンプレート独特の記述があるかもしれません)

```bash
git clone https://github.com/tookit/vue-material-admin.git
cd vue-material-admin
npm install
npm insatll axios vuex-persistedstate
```

# ログインAPIを作成
今回は「[Python FlaskでREST APIを作る](https://qiita.com/trustbank_kei/items/c72c57b54332431c67ec)」にて以前作成したAPIを利用します。
Pythonでなくても、`node.js` 等で、`アカウント名` と `パスワード` を受け取って、正しい組み合わせならトークンを返すAPIを作成できれば問題ありません。

# Vuex で store の仕組みを作る
`Vuex` を利用することで、**ローカルストレージ** や **セッションストレージ**、または **Cookie**に任意のデータを保持して、各ソース上からデータにアクセスしたり、書き換えたりできます。
今回はログイン後にAPIから返ってきた **アクセストークン** を **ローカルストレージ** に保持する仕組みを作ります。
また、画面に再度アクセスしてもデータが残り続けるよう、 `vuex-persistedstate` を使います。

### src/store/module/auth.js (ログイントークン保持)

```javascript
import Vue from "vue"

const auth = {
  state: {
    login: {
      token: false,
      name: "ゲスト",
      expire: 0
    }
  },
  mutations: {
    SET_LOGIN_INFO: (state, login) => {
      state.login.token  = login.token                     // ログイントークン
      state.login.name   = login.name                      // ユーザー名
      state.login.expire = Math.floor(1000 * login.expire) // APIからUNIXタイム(秒)で有効期限が返ってくるものとし、ミリ秒に変換しておく
    }
  },
  actions: {
    setLoginInfo({ commit }, login) {
      commit("SET_LOGIN_INFO", login)
    }
  }
}

export default auth
```

### src/store/index.js

```javascript
import Vue from "vue"
import Vuex from "vuex"
import createPersistedState from "vuex-persistedstate"

import auth from "./module/auth"

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    auth
  },
  state: {},
  mutations: {},
  actions: {},
  plugins: [createPersistedState({
    key: "xxxproject",     // プロジェクト単位の一意の識別子
    paths: ["auth.login"], // auth.js の loginキーは再度アクセスしても保持するようにする
    storage: localStorage  // 今回は localStorage に保存することにする
  })]
})
```

# 未ログインならログイン画面にリダイレクトする仕組みを作る

Vue.js ルーティングの仕組みを用いて、storeのトークンが無ければログイン画面にリダイレクトするようにします。

### src/router/index.js

```javascript
import Vue from "vue"
import Router from "vue-router"
import NProgress from "nprogress"
import "nprogress/nprogress.css"
import store from "@/store/index.js"

Vue.use(Router)
const router = new Router({
  mode: "history", // URLはハッシュ形式「#～～」ではなく「/～～」とする
  linkActiveClass: "active",
  routes: `ルーティング設定を記述 (略)`
})
// router gards
router.beforeEach((to, from, next) => {
  NProgress.start()
  // トークンが存在、かつログイン有効期限を過ぎてない場合、またはログイン画面の場合
  if ((store.state.auth.login.token && store.state.auth.login.expire > (new Date()).getTime()) || to.matched.some(page => {
    // ログイン画面はリダイレクト対象外 (他にも404ページなどいくつか対象外にする必要があるかも)
    return (page.path === '/login')
  })) {
    next()
  } else {
    // ログイン画面に飛ばす。ログイン後に元の画面に戻れるよう、backuriパラメーターにリダイレクト前のURLを入れる
    next({path: '/login', query: {backuri: to.fullPath}})
  }
})

router.afterEach((to, from) => {
  NProgress.done()
})

export default router
```

ここでは **トークンが本当に正しい内容かどうか** までは検証していません。
トークンの改竄を防ぐため、 **APIにアクセスしてトークンが正しいかどうかを検証する** のもありですが、トークンが正しくなければ後続の各種データ取得でのAPIアクセスに失敗するので、そこで検知することとしました。

# ログイン画面の作成

### src/views/Login.vue

```vuejs
<template>
  <v-card class="elevation-1 pa-3 login-card">
    <v-card-text>
      <div class="layout column align-center">
        <h1 class="flex my-4 primary--text font-weight-bold">ログイン</h1>
      </div>
      <v-form ref="loginForm">
        <v-text-field
          append-icon="person"
          name="login"
          label="メールアドレス"
          type="text"
          v-model="model.email"
          :counter="128"
          :rules="emailRules"
          required
        ></v-text-field>
        <v-text-field
          append-icon="lock"
          name="password"
          label="パスワード"
          id="password"
          type="password"
          v-model="model.password"
          :counter="32"
          :rules="passwordRules"
          required
        ></v-text-field>
      </v-form>
    </v-card-text>
    <div class="login-btn">
      <v-btn block color="primary" @click="login" :loading="loading">ログイン</v-btn>
    </div>
  </v-card>
</template>

<script>
import Axios from "axios"
export default {
  data: () => ({
    loading: false,
    emailRules: [
      v => !!v || "メールアドレスは必須項目です。",
      v => (v && v.length <= 128) || "メールアドレスは128文字以内で入力してください。",
      v => /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(v) || "メールアドレスの形式が正しくありません。"
    ],
    passwordRules: [
      v => !!v || "パスワードは必須項目です。",
      v => (v && v.length <= 32) || "パスワードは32文字以内で入力してください。"
    ],
    model: {
      email: "",
      password: ""
    }
  }),

  methods: {
    login() {
      // バリデーションが通った場合
      if (this.$refs.loginForm.validate()) {
        // ぐるぐる表示にしてボタンを二度押しできなくする
        this.loading = true
        // APIでログイン認証を行う
        Axios.post("/api/auth/login", this.model).then(res => {
          // 成功した場合
          if (res.data.result) {
            // ログイン情報を store に保存
            this.$store.dispatch("setLoginInfo", res.data)
            // 元の画面に戻る
            this.$router.push({path: "backuri" in this.$route.query && this.$route.query.backuri.match(/^\//) ? this.$route.query.backuri : '/'})
          // メールアドレスとパスワードが正しくない組み合わせだった場合
          } else {
            this.loading = false
            alert(Object.values(res.data.errors).join("\n"))
          }
        }).catch(error => {
          alert("処理が正しく行えませんでした。時間をおいてやり直してください。")
          this.loading = false
        })
      }
    }
  }
}
</script>
```

# その後の、各種ページでのAPIからのデータ取得

ログインに成功した後、各種ページでトークンを使ってデータを取得してみます。

### src/views/Hoge.vue

```vuejs
<script>
import Axios from "axios"
import Vue from "vue"
import store from "@/store/index.js"
export default {
  data: () => ({
    model: {
      title: "", // ここでは API から title, description を取得するものとします
      description: ""
    }
  }),
  methods: {
    loadData() {
      // APIにアクセスして情報を取得
      Axios.get("/api/data", {
        headers: {
          "Authorization": "Bearer " + store.state.auth.login.token
        }
      }).then(res => {
        // 成功時、取得した情報を格納
        this.model = res.data
      }).catch(error => {
        // トークンが正しくなければログイン画面にリダイレクト
        if (error.response.status == 401) {
          this.$router.push({path: "/login", query: {backuri: this.$route.fullPath}})
        } else {
          alert("情報を取得できませんでした。時間をおいてやり直してください。")
        }
      })
    }
  },
  created: function() {
    // 画面アクセス時にAPIにアクセスするようにする
    this.loadData()
  }
}
</script>
```

# ビルドして実行

```bash
npm run build
```

`https://~~/hoge` にアクセスして、`https://~~/login?backuri=%2Fhoge` に飛ばされることを確認。