76
81

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.

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

Last updated at Posted at 2019-08-25

概要

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

注意事項

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

セットアップ

今回は、 Vuetify をベースにした Adminテンプレート Vue Material Admin を使います。
こちらを使わなくても、素の Vuetify でプロジェクトを作成しても特に問題無く進められると思います。
(できるだけ削ったつもりですが、一部、Adminテンプレート独特の記述があるかもしれません)

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を作る」にて以前作成したAPIを利用します。
Pythonでなくても、node.js 等で、アカウント名パスワード を受け取って、正しい組み合わせならトークンを返すAPIを作成できれば問題ありません。

Vuex で store の仕組みを作る

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

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

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

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

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

<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

<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>

ビルドして実行

npm run build

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

76
81
0

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
76
81

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?