LoginSignup
230
252

More than 3 years have passed since last update.

Vue.js で簡単なログイン画面 (トークン認証) を作ってみた

Last updated at Posted at 2019-01-23

ログインに成功すると token を取得できて
以降、認証が必要な API と通信する際は header に token をセットする、という想定です

login.gif

ちなみにバックエンドの API は Rails で作っています。
Rails でトークン認証 API を 15 分で実装する

ディレクトリ構成

store の使い方が肝でしょうか
うーん、状態管理って難しいですね

src/
├── App.vue
├── assets
│   └── logo.png
├── components
│   ├── error.vue
│   ├── login.vue
│   ├── logout.vue
│   ├── menu.vue
│   └── top.vue
├── lang
│   ├── index.js
│   └── messages.json # エラーメッセージ等、文言をここに
├── main.js
├── router
│   └── index.js # ルーティング定義
└── store
    ├── index.js
    └── modules
        ├── auth.js # token を管理
        ├── http.js # API と通信する際はコレを使う
        └── message.js # エラーメッセージの管理

vue-cli のインストール

Vue CLI が大変便利ですね
サクっと環境構築してくれます

$ npm install -g vue-cli
$ vue init webpack my-project
$ cd !$
$ npm run dev

http://localhost:8080 にアクセスするとこの画面が見れると思います

Screen Shot 2019-01-23 at 9.56.20.png

各ファイルのソース

主要なソースを載せてみます
まだ開発中で、改善の余地が多分にあると思います
何かありましたらコメント頂けるとありがたいです!

main.js

  • router (画面遷移管理) や store (状態管理) を注入しています
  • vee-validate は入力フォームのバリデーションにとても便利ですね
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import VeeValidate from 'vee-validate'
import App from '@/App'
import router from '@/router'
import store from '@/store'
import lang from '@/lang'

Vue.use(VeeValidate)
Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  components: { App },
  el: '#app',
  i18n: lang,
  router,
  store,
  template: '<App/>'
})

router

router/index.js

  • 1 つずつコンポーネントをロードして URL と紐づけています
  • meta でメタ情報を設定できます。isPublic: true じゃない時は token が必要、という風に作ってみました
  • router.beforeEach でログインが必要な画面かどうか、ログイン済かどうかを判定しています
import Vue from 'vue'
import Router from 'vue-router'

// components
import Top from '@/components/top.vue'
import Login from '@/components/login.vue'
import Logout from '@/components/logout.vue'
import Error from '@/components/error.vue'

// store
import Store from '@/store/index.js'

Vue.use(Router)

const router = new Router({
  routes: [
    {
      path: '/',
      name: 'top',
      component: Top,
      meta: {
        isPublic: true
      }
    },
    {
      path: '/login',
      name: 'login',
      component: Login,
      meta: {
        isPublic: true
      }
    },
    {
      path: '/logout',
      name: 'logout',
      component: Logout
    },
    {
      path: '/error',
      name: 'error',
      component: Error
    },
    {
      path: '*',
      redirect: '/error'
    }
  ]
})

router.beforeEach((to, from, next) => {
  if (to.matched.some(page => page.meta.isPublic) || Store.state.auth.token) {
    next()
  } else {
    next('/login')
  }
})

export default router

store

store/index.js

  • store が膨大になりそうなのでモジュールで分割してみました
  • vuex-persistedstate を入れてブラウザをリロードしても store が保持されるようにしています
import Vue from 'vue'
import Vuex from 'vuex'

import createPersistedState from 'vuex-persistedstate'

import auth from '@/store/modules/auth'
import http from '@/store/modules/http'
import message from '@/store/modules/message'

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    auth,
    http,
    message
  },
  plugins: [createPersistedState({
    key: 'example',
    storage: window.sessionStorage
  })]
})

store/modules/auth.js

ここをゴニョゴニョがんばってみました

  • token を管理するモジュールです
  • commit('create', res.data) で自分の mutations をコールしています
  • dispatch('http/delete', { url: '/auth', data }, { root: true }) で他の actions をコールしています。他のモジュールをコールする時は第 3 引数で root: true しないとエラーになります。ちなみに別モジュールにした理由は http リクエスト処理を共通化したかったためです。
  • 大体の流れとして他から actions がコールされる -> actions が mutations をコール -> mutations が state の値を変更する、みたいな流れになるのかな? mutations はセッターみたいな感じですね
export default {
  namespaced: true,
  state: {
    tenant: '',
    userId: '',
    token: ''
  },
  mutations: {
    create (state, data) {
      state.tenant = ''
      state.token = data.token
      state.userId = data.user_id
    },
    destroy (state) {
      state.tenant = ''
      state.userId = ''
      state.token = ''
    }
  },
  actions: {
    create ({ commit, dispatch }, data) {
      dispatch(
        'http/post',
        { url: '/auth', data, error: 'message.unauthorized' },
        { root: true }
      ).then(res => commit('create', res.data))
        .catch(err => err)
    },
    destroy ({ commit, dispatch }, data) {
      dispatch(
        'http/delete',
        { url: '/auth', data },
        { root: true }
      ).then(res => commit('create', res.data))
        .catch(err => err)
        // logout anyway ...
        .finally(res => commit('destroy'))
    }
  }
}

store/modules/http.js

  • http リクエストのモジュールです
  • ヘッダーのheaders['Authorization']に発行された token をセットします
  • request ({ dispatch, rootState }, ...) の第 1 引数に rootState を入れ、auth.js の rootState.auth.token を引っ張ってきています
  • async request() の下にわざわざ async post() とか async delete() とか作っているのはコールする時に dispatch('http/post', ...)dispatch('http/delete', ...) したかったからです (好み)
import axios from 'axios'

export default {
  namespaced: true,
  actions: {
    async request ({ dispatch, rootState }, { method, url, data, error }) {
      const headers = {}
      headers['Content-Type'] = 'application/json'
      if (rootState.auth.token) {
        headers['Authorization'] = `Token ${rootState.auth.token}`
        headers['User-Id'] = rootState.auth.userId
      }

      const options = {
        method,
        url: `${process.env.API_URL}${url}`,
        headers,
        data,
        timeout: 15000
      }

      return axios(options)
        .then(res => res)
        .catch(err => {
          dispatch(
            'message/create',
            { error: error, err },
            { root: true }
          )
        })
    },
    async post ({ dispatch }, requests) {
      requests.method = 'post'
      return dispatch('request', requests)
    },
    async delete ({ dispatch }, requests) {
      requests.method = 'delete'
      return dispatch('request', requests)
    }
  }
}

components

components/login.vue

  • watch で store の token を監視し、ログイン済 (token に値が入る) になったら画面遷移させています
  • 既にログイン済だった場合もとりあえずトップに飛ばしています
  • isValidated ()で入力フォームのバリデーションが通った場合にボタンをアクティブにするようにしています
  • {{ $t(errorMessage) }}で lang/messages.json の文言を引っ張ってきています
<template>
  <div id="app">
    <section class="hero is-light is-fullheight">
      <div class="hero-body">
        <div class="container has-text-centered">
          <article v-show="errorMessage" class="message is-warning">
            <div class="message-body">
              {{ $t(errorMessage) }}
            </div>
          </article>
          <div class="column is-4 is-offset-4">
            <div class="box">
               <div class="field">
                 <div class="control">
                   <input class="input is-large" type="email" placeholder="Eメール" v-model="email" autofocus="" v-validate="'required|email'" name="email">
                 </div>
              </div>
              <div class="field">
                <div class="control">
                  <input class="input is-large" type="password" placeholder="パスワード" v-model="password" v-validate="'required|min:6|max:20'" maxlength="20" name="password">
                </div>
              </div>
              <div class="field">
                <label class="checkbox">
                <input type="checkbox">
                ログインしたままにする
                </label>
               </div>
               <button class="button is-block is-info is-large is-fullwidth" @click="login()" :disabled="!isValidated" >É≠„Ç∞„ǧ„É</button>
              </div>
              <p class="has-text-grey">
                <a href="..">パスワードを忘れた方はこちら</a>
              </p>
            </div>
          </div>
        </div>
    </section>
  </div>
</template>

<script>
export default {
  data () {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    login () {
      this.$store.dispatch(
        'auth/create',
        {
          'user': {
            email: this.email,
            password: this.password
          }
        }
      )
    }
  },
  computed: {
    isValidated () {
      return Object.keys(this.fields).every(k => this.fields[k].validated) &&
        Object.keys(this.fields).every(k => this.fields[k].valid)
    },
    token () {
      return this.$store.state.auth.token
    },
    errorMessage () {
      return this.$store.state.message.error
    }
  },
  created: function () {
    this.$store.dispatch('message/destroy')
    // already logined
    if (this.$store.state.auth.token) {
      this.$router.push('/')
    }
  },
  watch: {
    token (newToken) {
      this.$router.push('/')
    }
  }
}
</script>
230
252
4

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