nuxt.js+Firebaseで認証ページを作る④

認証機能実装編

さて今回がいよいよ、ラストの認証機能実装となります。

これまでの中では一番理解が難しい回かと思いますので
理解しやすい手順で進めていきたいと思います。


今回のシリーズの全リンクはこちら

nuxt.js+Firebaseで認証ページを作る① (Firebase準備編)
nuxt.js+Firebaseで認証ページを作る② (nuxt.jsインストール編)
nuxt.js+Firebaseで認証ページを作る③ (pages&layouts準備編)
nuxt.js+Firebaseで認証ページを作る④ (認証機能実装編)

まずは認証機能実装に必要なnodeモジュールをインストールしていきましょう。

ターミナルを起動して
cd コマンドでプロジェクトルートのディレクトリまで移動しましょう。
プロジェクトルートに移動できたら下記を実行します。

npm i cookieparser js-cookie

storeにindex.jsを用意

プロジェクトルートで下記のコマンドを実行しましょう。

touch store/index.js


index.jsにstoreに必要なメソッド
state、mutations、action、gettersを
定義していきましょう。
ついでにstrictもfalseにしときます。

export const strict = false

export const state = () => ({})

export const mutations = {}

export const actions = {}

export const getters = {}

続いてユーザーidやメールアドレスなどの
認証済みユーザーの情報を入れておくstateと
stateの更新するためのmutationを作っていきましょう。

export const strict = false

export const state = () => ({
  auth: null
})

export const mutations = {
  mutateAuth(state, auth){
    state.auth = auth
  }
}

export const actions = {}

export const getters = {}

このmutateAuthメソッドで
ログインする際はユーザー情報を入れて
ログアウトする際はstate.authにnullを入れて認証情報を削除してやる事で
ログアウトができるという感じです。

ログイン画面のバリデーション機能を実装しよう。

認証画面においてメールアドレスやパスワードのバリデーションなどの機能は重要ですが
ここでは、さらっとやっていきます。
layouts/login.vueのtemplateの部分を下記のようにします。
すぐ後にloginのためのメソッドも定義するので
先にLoginボタンの@clickにloginメソッドを当てておきましょう。

<template>
  <v-app id="inspire">
    <v-content>
      <v-container
        class="fill-height"
        fluid
      >
        <v-row
          align="center"
          justify="center"
        >
          <v-col
            cols="12"
            sm="8"
            md="4"
          >
            <v-card class="elevation-12">
              <v-toolbar
                color="primary"
                dark
                flat
              >
                <v-toolbar-title>Login form</v-toolbar-title>
                
              </v-toolbar>
              <v-card-text>
                <v-form ref="loginForm" v-model="loginForm.valid">
                  <v-text-field
                    v-model="loginForm.email.model"
                    :rules="loginForm.email.rules"
                    label="Login"
                    name="login"
                    prepend-icon="person"
                    type="text"
                  ></v-text-field>

                  <v-text-field
                    v-model="loginForm.password.model"
                    :rules="loginForm.password.rules"
                    id="password"
                    label="Password"
                    name="password"
                    prepend-icon="lock"
                    type="password"
                  ></v-text-field>
                </v-form>
              </v-card-text>
              <v-card-actions>
                <div class="flex-grow-1"></div>
                <v-btn color="primary" @click="login">Login</v-btn>
              </v-card-actions>
            </v-card>
          </v-col>
        </v-row>
      </v-container>
    </v-content>
  </v-app>
</template>

バリデーションを有効にするためにデータモデルも作りましょう。
templateタグの下に下記scriptタグを追加します。

<script>
  export default {
    data() {
      return {
        loginForm: {
          valid: true,
          email: {
            model: '',
            rules: [
              v => !!v || 'メールアドレスが未入力です。',
              v => /.+@.+/.test(v) || '正しいメールアドレスを入力してください。'
            ]
          },
          password: {
            model: '',
            rules: [
              v => !!v || 'パスワードが未入力です。'
            ]
          }
        }
      }
    }
  }
</script>

いよいよログイン実装

loginメソッドを定義する前に
必要なモジュールをインポートしておきましょう。
第二回の時に作成したfirebaseのプラグインからfireAuthオブジェクト、
あとは先ほどインストールしたnodeモジュールjs-cookieをインポートします。

<script>
  import { fireAuth } from '~/plugins/firebase'
  const Cookie = process.client ? require('js-cookie') : undefined

  export default {
    data() {
      return {
        loginForm: {
          valid: true,
          email: {
            model: '',
            rules: [
              v => !!v || 'メールアドレスが未入力です。',
              v => /.+@.+/.test(v) || '正しいメールアドレスを入力してください。'
            ]
          },
          password: {
            model: '',
            rules: [
              v => !!v || 'パスワードが未入力です。'
            ]
          }
        }
      }
    }
  }
</script>

さてこれで、loginメソッドを定義する準備ができましたので
早速、loginメソッドを作って行きましょう!
ちょっと、ソースコードが長くなってしまうので
dataの中身は省略します。
dataの下にmethodsを追加してlogin()メソッドを作ってやります。

<script>
  import { fireAuth } from '~/plugins/firebase'
  const Cookie = process.client ? require('js-cookie') : undefined

  export default {
    data() {
      ...省略
    },
    methods: {
      login() {

      }
    },
  }
</script>

まず最初は兎にも角にもログインボタンがクリックされた際の
バリデーションの結果に問題ないかチェックしないといけません。
なのでまずは、バリデーションチェックしましょう。
(更にmethods内以外省略)

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }
}

さて次がようやくfirebaseのauthenticationを使って認証を行う部分です。
setTimeoutはあまり気にする必要はありません。
認証っぽくローダーを回すのに少しだけ遅延させているくらいの感じです。
今回はローダー使っていませんが;;

signInWithEmailAndPasswordメソッドにはフォームに入力したメールアドレスとパスワードを渡しています。
これでfirebaseにメールアドレスとパスワードが一致するユーザーが存在していれば
無事に返り値に認証情報が返ってきます。
ユーザーが存在しなかった場合やエラーの場合はnullが返ってきます。

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }

  setTimeout(async () => { // 非同期リクエストのタイムアウトをシミュレートします
      const auth = await fireAuth.signInWithEmailAndPassword(this.loginForm.email.model, this.loginForm.password.model).catch(err => {
        return null
      })
    }, 1000)
  }

}

ではfirebaseにちゃんと登録済みで無事に認証情報が返ってきたとして
その取得できた認証情報を先ほど実装したstoreのstate.authにいれてやりましょう。
そうする事でこの後認証後の「/admin」へページ遷移しますが
ページ遷移をしても認証情報が保持された状態になります。
Cookieにも認証情報を保存していますが、その理由はこの後すぐわかります。

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }

  setTimeout(async () => { // 非同期リクエストのタイムアウトをシミュレートします
    const auth = await fireAuth.signInWithEmailAndPassword(this.loginForm.email.model, this.loginForm.password.model).catch(err => {
      return null
    })

    if(auth){
      this.$store.commit('mutateAuth', auth) // クライアントレンダリング用に変更する
      Cookie.set('auth', auth) // サーバサイドレンダリングのためにクッキーに認証情報を保存する
      this.$router.push('/admin')
    }

  }, 1000)
}

それでは、firebaseに登録してあるメールアドレスとパスワードをフォームに入力してログインボタンをクリックしてみましょう。
するとちゃんと認証後の「/admin」ページへ遷移できたと思います。
https://i.imgur.com/2o3OjXD.png

しかし、ここで気になるのが、認証後の状態で「/admin/login」のURLにアクセスするとどうなるのでしょうか?
既に認証が終わっているのですから「/admin/login」にアクセスした場合「認証済み」という事で「/admin」へリダイレクトして欲しいものです。
試しに「/admin/login」へアクセスしてみましょう。

https://i.imgur.com/u6XdOYM.png

はい、見事にフラグ回収しましたね。
予想通り「/admin」へリダイレクトはしてくれませんでしたね。
それもそのはず、リダイレクトさせる処理などまだどこにも作っていませんから
リダイレクトなどしてくれるはずもありません。

という事で早速リダイレクトさせる機能を作って行きましょう。
ちなみに認証済みの際に「/admin」へリダイレクトして欲しいのももちろんですが
もっと重要なのは、認証済みでない時は「/admin」も含めて認証が必要なページにアクセスされたら
「/admin/login」へリダイレクトさせるという機能ですよね。
これがなかったらセキュリティもクソも無いガバガバのwebサービスになってしまいますから
認証済みの際のリダイレクトよりも重要な機能です。
そちらも合わせて2つのリダイレクト機能を作って行きましょう。

middlewareでリダイレクト機能を作る

認証に関するリダイレクト機能はnuxt.jsのmiddlewareを使って実装して行きます。
それでは、ターミナルでプロジェクトルートへ移動し下記のコマンドを実行しましょう。

touch middleware/authenticated.js
touch middleware/notAuthenticated.js


そしてそれぞれ
authenticated.jsにはこちらの処理を

export default function ({ store, redirect, error}) {
  // 認証必要ページ用middleware
  if (!store.state.auth) {
    return redirect('/admin/login')
  }
}

notAuthenticated.jsにはこちらを書いていきましょう。

export default function ({ store, redirect }) {
  // 認証不要ページ用middleware
  // ただし、認証済みの場合は認証ルートページへリダイレクトする
  if (store.state.auth) {
    return redirect('/admin')
  }
}

なんのことは無いです。
たったこれだけのコードです。
先ほどログインの画面でstoreのstate.authに認証情報を入れてやりましたが
要はその認証情報を取ってきて
authenticated.jsの方は認証情報が無かった場合は「/admin/login」へリダイレクト
notAuthenticated.jsの方は認証情報があった場合は「/admin」へリダイレクトさせると
単純にそれだけの内容です。

ですがここで一つ注意なのがファイル名が「authenticated」、「notAuthenticated」と過去形なので一瞬
「authenticated」の方が認証情報があった場合の処理で
「notAuthenticated」が認証情報が無かった場合の処理のように思えて紛らわしいですが逆です。

「authenticated」は認証が必要なページの場合の処理です。
なので「authenticated.js」の方が認証が必要なページに利用するmiddlewareです。

逆に「notAuthenticated」は認証が必要のないページ、つまりログイン画面で利用するmiddlewareです。
紛らわしいのですがこのファイル名は一応、公式が使っている
ファイル名なのでそうしています。

紛らわしくて嫌という事であれば、自身が分かりやすい名前に変えてもらっても良いかと思います。

それでは、middlewareの実装ができたので
それぞれ、「/admin」ページには「authenticated」を
「/admin/login」ページには「notAuthenticated」を組み込んでいきましょう。

まずは、「pages/admin/index.vue」ファイルを開いて
「layout: 'admin'」とレイアウトの指定がしてある上に
middlewareの指定も追加してやります。
これで「/admin」ページではauthenticatedミドルウェアが機能するようになります。

<template>
  <div>
    admin
  </div>
</template>

<script>
  export default {
    middleware: 'authenticated',
    layout: 'admin'
  }
</script>

<style lang="scss" scoped>

</style>

続いて「/admin/login」ページも同じようにしてやります。

<template>
  <div>
    
  </div>
</template>

<script>
  export default {
    middleware: 'notAuthenticated',
    layout: 'login'
  }
</script>

<style lang="scss" scoped>

</style>

これで、各ページにアクセスしようとした際、middlewareが認証情報の有無をチェックしてくれた上で
適切なページへリダイレクトさせてくれるはずです。

早速、再びログイン画面のフォームにメールアドレスとパスワードを入れて
ログインしてみましょう。
https://i.imgur.com/2o3OjXD.png

先ほど同様ログイン出来ました。
ログインが出来たら「/admin/login」へアクセスしてみましょう。

middlewareが認証情報の有無をチェックしてくれるはずですからログアウトしない限りは
「/admin/login」へアクセスしたら「/admin」へリダイレクトしてくれるはずです。


ところがどうでしょうか?
リダイレクトはされず先ほどと変わらず
そのまま、「/admin/login」へアクセス出来てしまったと思います。

これは実はnuxtのstoreはjavascriptレンダリングによるページ遷移の範囲では
stateを保持してくれるのですが
URL欄に直接URLを入力してのアクセスや、ページのリロード、nuxt-linkではなくaタグによるページ遷移など
通常のページ遷移ではstateを保持してくれません。

試しにmiddlewareのstore.state.authを
console.logで書き出してみてください。
middlewareはサーバー側の処理になるのでターミナル側に出力されると思いますが
恐らく、「null」となっているはずです。

nuxtのstoreに保存されたデータはブラウザのリロードなどをすると消えてしまうのです。
ではどうやってブラウザでリロードされたとしても認証情報を維持できるのか?

ここで活躍するのが、冒頭でインストールしたjs-cookieなどのCookieに関するモジュールです。
cookieparserは、cookieに保存された変数の内容をjson形式に変換してくれるモジュールです。

それでは、middlewareでもちゃんとstate.authを取得できるようにしていきましょう。

nuxtのライフサイクルを確認しておこう。

まず、middlewareで認証情報を受け取れるようにする前に
middlewareというのがnuxtのライフサイクルのどの地点で行われている処理なのか見てみましょう。
https://i.imgur.com/m2HJsjY.png
こちらがnuxtのライフサイクルです。
middlewareで認証情報を受け取れるようにするには
middlewareより後に行われる処理でユーザー情報をstate.authに保存する事をやってやっても
意味はありません。
middlewareで認証情報を受け取りたいのであればmiddlewareが呼ばれる前の処理で
storeのstate.authに認証情報を格納してやる必要があります。

middlewareが呼ばれる前の処理というと、この図を見ればわかる通り「nuxtServerInit」しかありません。
ちなみに「Request」というのはユーザーがページにアクセスするという事なのでnuxtの処理ではないです。

それでは、middlewareで認証情報を受け取るにはnuxtがnuxtServerInitという処理を行うタイミングで
storeのstate.authに認証情報を入れてやればいいということが分かりましたが
実際にはどう実装すればいいのでしょうか?

実は、nuxtのstoreのactionには特別なactionがありそれが「nuxtServerInit」というアクションになります。
ということは、nuxtServerInitはstoreのアクションに実装してやればいいということです。

nuxtServerInitに認証情報を保存するコードを書いていく

それではまず、「layouts/login.vue」のloginメソッドをもう一度確認しておきます。

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }

  setTimeout(async () => { // 非同期リクエストのタイムアウトをシミュレートします
    const auth = await fireAuth.signInWithEmailAndPassword(this.loginForm.email.model, this.loginForm.password.model).catch(err => {
      return null
    })

    if(auth){
      this.$store.commit('mutateAuth', auth) // クライアントレンダリング用に変更する
      Cookie.set('auth', auth) // サーバサイドレンダリングのためにクッキーに認証情報を保存する
      this.$router.push('/admin')
    }

  }, 1000)
}

Cookie.set('auth', auth)でログイン時にCookieに「auth」という名前で認証情報を保存しています。
こいつをnuxtServerInitアクションの方で受け取ってやります。

それでは「store/index.js」を開いて
まず、必要なモジュールをインポートしてやりましょう。
firebaseのプラグインからfireAuthをインポート
あと、cookieから認証情報を抽出するための「cookieparser」もインポートします。

import { fireAuth } from '@/plugins/firebase'
const cookieparser = process.server ? require('cookieparser') : undefined

export const strict = false

export const state = () => ({
  auth: null
})

export const mutations = {
  mutateAuth(state, auth){
    state.auth = auth
  }
}

export const actions = {}

export const getters = {}

必要なモジュールをインポート出来たら
actionsにnuxtServerInitを実装していきます。

import { fireAuth } from '@/plugins/firebase'
const cookieparser = process.server ? require('cookieparser') : undefined

export const strict = false

export const state = () => ({
  auth: null
})

export const mutations = {
  mutateAuth(state, auth){
    state.auth = auth
  }
}

export const actions = {
  nuxtServerInit({ commit }, { req }) {
    let auth = null
    if (req.headers.cookie) {
      //cookieのauth変数をjson形式で取得
      const parsed = cookieparser.parse(req.headers.cookie)
      try {
        // 認証情報を取得
        auth = JSON.parse(parsed.auth)
      } catch (err) {
        // cookieに認証情報がない場合はfirebase側もログアウトの処理をしておく
        fireAuth.signOut()
      }
    }
    // 認証情報がちゃんと取得できていればstate.authに認証情報が、認証情報が取得できなかった場合はnullが入る
    commit('mutateAuth', auth)
  }
}

export const getters = {}

これで、クライアント側でCookieに保存した認証情報を
サーバー側でも扱えるようになったため
middlewareでstore.state.authが取れるようになりました。
これで今度こそ認証リダイレクトがうまく動作しているはずです。

最後にログアウトボタンも作っておく

以上で認証機能実装はできているはずですが
認証機能にログアウトボタンは、もちろん必要となりますし
認証機能が本当にちゃんと実装できているのか確かめる為にも必要です。

今のままでは、ログアウトができないので
ログインした後は二度と「/admin/login」を使うことは無いという状態になってしまいます。
それでは困るので最後にログアウトボタンも作っておきましょう。

「layouts/admin.vue」を開いて
templateタグ内を下記と差し替えましょう。
単に< v-btn color="primary" @click="logout" >ログアウト< /v-btn >が加わっただけです。

<v-app dark>
  <v-navigation-drawer
    v-model="drawer"
    :mini-variant="miniVariant"
    :clipped="clipped"
    fixed
    app
  >
    <v-list>
      <v-list-item
        v-for="(item, i) in items"
        :key="i"
        :to="item.to"
        router
        exact
      >
        <v-list-item-action>
          <v-icon>{{ item.icon }}</v-icon>
        </v-list-item-action>
        <v-list-item-content>
          <v-list-item-title v-text="item.title" />
        </v-list-item-content>
      </v-list-item>
    </v-list>
  </v-navigation-drawer>
  <v-app-bar
    :clipped-left="clipped"
    fixed
    app
  >
    <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
    <v-btn
      icon
      @click.stop="miniVariant = !miniVariant"
    >
      <v-icon>mdi-{{ `chevron-${miniVariant ? 'right' : 'left'}` }}</v-icon>
    </v-btn>
    <v-btn
      icon
      @click.stop="clipped = !clipped"
    >
      <v-icon>mdi-application</v-icon>
    </v-btn>
    <v-btn
      icon
      @click.stop="fixed = !fixed"
    >
      <v-icon>mdi-minus</v-icon>
    </v-btn>
    <v-toolbar-title v-text="title" />
    <v-spacer />
    <v-btn
      icon
      @click.stop="rightDrawer = !rightDrawer"
    >
      <v-icon>mdi-menu</v-icon>
    </v-btn>
  </v-app-bar>
  <v-content>
    <v-container>

      <v-btn color="primary" @click="logout">ログアウト</v-btn>

      <nuxt />
    </v-container>
  </v-content>
  <v-navigation-drawer
    v-model="rightDrawer"
    :right="right"
    temporary
    fixed
  >
    <v-list>
      <v-list-item @click.native="right = !right">
        <v-list-item-action>
          <v-icon light>
            mdi-repeat
          </v-icon>
        </v-list-item-action>
        <v-list-item-title>Switch drawer (click me)</v-list-item-title>
      </v-list-item>
    </v-list>
  </v-navigation-drawer>
  <v-footer
    :fixed="fixed"
    app
  >
    <span>&copy; 2019</span>
  </v-footer>
</v-app>

続いてscriptタグ内、
まずは、おなじみのモジュールをインポート

import { fireAuth } from '~/plugins/firebase'
const Cookie = process.client ? require('js-cookie') : undefined

そして、methodsにlogoutメソッドを定義しましょう。

methods: {
  logout() {
    Cookie.remove('auth')
    this.$store.commit('mutateAuth', null)
    fireAuth.signOut()
    this.$router.push('/admin/login')
  }
}

こちらは、loginメソッドと違いシンプルです。
cookieに保存された「auth」を削除
storeのstate.authをnullに
firebaseの方もログアウト
そして最後に「/admin/login」へリダイレクト
それだけの内容です。

まとめ

以上、がnuxt.jsとfirebase authenticationを用いた
認証画面の実装のやり方となります。
4回にも分けてやってきましたが

まぁまぁな作業量なので
おそらく、作業の区切った方が
身につきやすいだろうと思いチュートリアル風に記事を書いてみました。

このシリーズは終りですが、今後もnuxt.js周りでの発見などあれば
今回ほどのシリーズ投稿はしないかもですが
メモ的な感じで投稿していこうかと思います。

今回のシリーズで作った認証画面のサンプルはこちらにおいてあります。