概要
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
に飛ばされることを確認。